From c2a4b9d1a60f7782b9bb2ce9b007e8c889f575d5 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 17 Jul 2026 19:43:47 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=A7=D9=84=D9=85=D8=AC=D9=85=D9=88?= =?UTF-8?q?=D8=B9=D8=A9=20C=20=E2=80=94=20=D8=A8=D9=8A=D8=A7=D9=86=D8=A7?= =?UTF-8?q?=D8=AA=20=D8=A7=D9=84=D8=B3=D8=A7=D8=A6=D9=82=20=D9=88=D8=A7?= =?UTF-8?q?=D9=84=D9=85=D8=B1=D9=83=D8=A8=D8=A9=20(=D9=83=D9=8A=D8=A7?= =?UTF-8?q?=D9=86=20=D9=85=D8=B1=D9=83=D8=A8=D8=A9=20+=20=D9=85=D9=84?= =?UTF-8?q?=D9=81=20=D8=A7=D9=84=D8=B3=D8=A7=D8=A6=D9=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/app.module.ts | 2 + .../1721910000000-VehiclesAndDriverProfile.ts | 76 +++++++++++++ .../modules/documents/documents.service.ts | 5 +- .../entities/driver-document.entity.ts | 3 +- .../src/modules/drivers/drivers.controller.ts | 21 ++++ .../src/modules/drivers/drivers.service.ts | 39 +++++++ .../modules/drivers/entities/driver.entity.ts | 45 ++++++++ .../vehicles/entities/vehicle.entity.ts | 83 ++++++++++++++ .../modules/vehicles/vehicles.controller.ts | 90 +++++++++++++++ .../src/modules/vehicles/vehicles.module.ts | 14 +++ .../modules/vehicles/vehicles.service.spec.ts | 105 ++++++++++++++++++ .../src/modules/vehicles/vehicles.service.ts | 96 ++++++++++++++++ docs/17-backend-backlog.md | 19 ++-- 13 files changed, 588 insertions(+), 10 deletions(-) create mode 100644 backend/src/database/migrations/1721910000000-VehiclesAndDriverProfile.ts create mode 100644 backend/src/modules/vehicles/entities/vehicle.entity.ts create mode 100644 backend/src/modules/vehicles/vehicles.controller.ts create mode 100644 backend/src/modules/vehicles/vehicles.module.ts create mode 100644 backend/src/modules/vehicles/vehicles.service.spec.ts create mode 100644 backend/src/modules/vehicles/vehicles.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a515a96..3f27422 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/database/migrations/1721910000000-VehiclesAndDriverProfile.ts b/backend/src/database/migrations/1721910000000-VehiclesAndDriverProfile.ts new file mode 100644 index 0000000..9947adb --- /dev/null +++ b/backend/src/database/migrations/1721910000000-VehiclesAndDriverProfile.ts @@ -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 { + 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 { + 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`); + } +} diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts index a4e28f8..7cbc50b 100644 --- a/backend/src/modules/documents/documents.service.ts +++ b/backend/src/modules/documents/documents.service.ts @@ -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 { diff --git a/backend/src/modules/documents/entities/driver-document.entity.ts b/backend/src/modules/documents/entities/driver-document.entity.ts index 939cfd0..f5f9ce7 100644 --- a/backend/src/modules/documents/entities/driver-document.entity.ts +++ b/backend/src/modules/documents/entities/driver-document.entity.ts @@ -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'; diff --git a/backend/src/modules/drivers/drivers.controller.ts b/backend/src/modules/drivers/drivers.controller.ts index f624466..4957251 100644 --- a/backend/src/modules/drivers/drivers.controller.ts +++ b/backend/src/modules/drivers/drivers.controller.ts @@ -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); diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts index 989a0bf..b0d3452 100644 --- a/backend/src/modules/drivers/drivers.service.ts +++ b/backend/src/modules/drivers/drivers.service.ts @@ -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 & { national_number?: string }, + ): Promise { + const driver = await this.findByUser(tenantId, userId); + if (!driver) throw new NotFoundException('Driver profile not found'); + + const patch: Partial = {}; + 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 { + return this.repo.findOne({ + where: { tenant_id: tenantId, national_number_bidx: blindIndex(nationalNumber) }, + }); + } + async setRating(tenantId: string, driverId: string, rating: number): Promise { await this.repo.update( { tenant_id: tenantId, id: driverId }, diff --git a/backend/src/modules/drivers/entities/driver.entity.ts b/backend/src/modules/drivers/entities/driver.entity.ts index fd355b3..8bba803 100644 --- a/backend/src/modules/drivers/entities/driver.entity.ts +++ b/backend/src/modules/drivers/entities/driver.entity.ts @@ -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; + + @Column({ type: 'jsonb', default: {} }) + user_input: Record; + @CreateDateColumn() created_at: Date; diff --git a/backend/src/modules/vehicles/entities/vehicle.entity.ts b/backend/src/modules/vehicles/entities/vehicle.entity.ts new file mode 100644 index 0000000..5a4f048 --- /dev/null +++ b/backend/src/modules/vehicles/entities/vehicle.entity.ts @@ -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; + + @Column({ type: 'jsonb', default: {} }) + user_input: Record; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/vehicles/vehicles.controller.ts b/backend/src/modules/vehicles/vehicles.controller.ts new file mode 100644 index 0000000..4d4c826 --- /dev/null +++ b/backend/src/modules/vehicles/vehicles.controller.ts @@ -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 { + 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); + } +} diff --git a/backend/src/modules/vehicles/vehicles.module.ts b/backend/src/modules/vehicles/vehicles.module.ts new file mode 100644 index 0000000..b1fcb87 --- /dev/null +++ b/backend/src/modules/vehicles/vehicles.module.ts @@ -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 {} diff --git a/backend/src/modules/vehicles/vehicles.service.spec.ts b/backend/src/modules/vehicles/vehicles.service.spec.ts new file mode 100644 index 0000000..06dc712 --- /dev/null +++ b/backend/src/modules/vehicles/vehicles.service.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/vehicles/vehicles.service.ts b/backend/src/modules/vehicles/vehicles.service.ts new file mode 100644 index 0000000..ec06ad1 --- /dev/null +++ b/backend/src/modules/vehicles/vehicles.service.ts @@ -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, + ) {} + + list(tenantId: string, driverId: string): Promise { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index df2b3ca..df26799 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -54,15 +54,18 @@ --- -## المجموعة C — بيانات السائق والمركبة (من سيرو) +## المجموعة C — بيانات السائق والمركبة (من سيرو) — ✅ منفَّذة -| # | البند | التفصيل | -|---|-------|---------| -| C1 | **CarRegistration كامل** | `vin` · `car_plate` · `make` · `model` · `year` · `expiration_date` · **`color` + `color_hex`** (لتلوين السيارة في فلاتر) · `owner` · `fuel` · `isDefault` · `vehicle_category_id` · `fuel_type_id` · `status`. | -| C2 | **حقول السائق** | `gender` · `national_number` (فريد) · `name_arabic` · `first/last_name` · `birthdate` · `license_type/categories/issue/expiry` · `address` · `accountBank`/`bankCode` · `employmentType` · `maritalStatus` · **`rejected_reason`** (سبب رفض خدمة العملاء). | -| C3 | **`ai_data` + `user_input`** | تخزين مخرجات Gemini الخام **و** ما أدخله السائق — للمقارنة والتدقيق (نمط سيرو). | -| C4 | **صور السيارة ×2** | صورتان للمركبة (لا واحدة). | -| C5 | **فيديو/liveness للوجه** | تأكيد حيّ للوجه (غير السيلفي الثابت). | +| # | البند | الحالة | +|---|-------|--------| +| C1 | **CarRegistration كامل** | ✅ جدول `vehicles` (مكافئ CarRegistration) — `plate`/`vin` (مشفَّران) · `make`/`model`/`year` · **`color`+`color_hex`** · `fuel`/`owner`/`registration_expiry`/`category` · `is_default` · `status`. سائق قد يملك أكثر من مركبة؛ `VehiclesService` يضمن **افتراضية واحدة دائماً** (أول مركبة تلقائياً، حذف الافتراضية يرقّي غيرها). | +| C2 | **حقول السائق** | ✅ على `drivers`: `gender` · `national_number` (مشفَّر + فهرس أعمى فريد لكل مستأجر، نمط الهاتف) · `name_arabic` (مشفَّر) · `birthdate` · `address` · `license_type/categories/issue/expiry` · `rejected_reason`. عبر `PATCH /drivers/profile`. | +| C3 | **`ai_data` + `user_input`** | ✅ عمودان jsonb على `drivers` و`vehicles` — مخرجات Gemini الخام مقابل ما أدخله السائق (يُراكَم لا يُستبدَل، للمقارنة حقلاً بحقل). | +| C4 | **صور السيارة ×2** | ✅ `vehicle_photo` بـ`min: 2` في كتالوج الوثائق (`REQUIRED_DOCS`). | +| C5 | **فيديو/liveness للوجه** | ✅ نوع وثيقة `face_liveness` جديد — الرفع يقبل الفيديو (لا قيد mime)، ومفصول عن مسار مطابقة Gemini للصور. | + +**قرار تصميم**: المركبة كيان مستقل (`vehicles`) لا حقول مسطّحة على السائق — سيرو نفسه يفصلها (`CarRegistration` مع `isDefault`)، وسائق واحد قد يبدّل مركبته. حقول `vehicle_make/model/plate/color` القديمة على `drivers` تبقى للتوافق؛ المصدر الجديد هو `vehicles`. +**مؤجَّل بوعي**: مطابقة الوجه الآلية على `face_liveness` (استخراج إطار + Gemini vision) — البنية جاهزة، والمطابقة الفعلية عند تفعيل مزوّد الرؤية. `accountBank`/`employmentType`/`maritalStatus` من سيرو تُضاف عند الحاجة (ليست حرجة للتشغيل). ---