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:
co-authored by
Claude Opus 4.8
parent
4e9d4d9be1
commit
c2a4b9d1a6
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user