This commit is contained in:
natib21
2026-06-18 09:46:35 +00:00
parent 96ad7bd152
commit fc702f0fbe
16 changed files with 698 additions and 2 deletions

View File

@@ -60,6 +60,8 @@ import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
@Module({
imports: [
@@ -119,6 +121,8 @@ import { OverviewModule } from './modules/overview/overview.module';
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
DriversModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
CREATE TABLE freight.vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plate_number VARCHAR NOT NULL UNIQUE,
registration_number VARCHAR NOT NULL UNIQUE,
vehicle_type VARCHAR NOT NULL,
manufacturer VARCHAR NOT NULL,
model VARCHAR NOT NULL,
year INTEGER NOT NULL,
fuel_type VARCHAR NOT NULL,
capacity NUMERIC NOT NULL,
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
);
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateDriversTable1775000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'drivers' AND table_schema = 'freight') THEN
CREATE TABLE freight.drivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_number VARCHAR NOT NULL UNIQUE,
first_name VARCHAR NOT NULL,
last_name VARCHAR NOT NULL,
email VARCHAR NOT NULL UNIQUE,
phone_number VARCHAR NOT NULL UNIQUE,
date_of_birth DATE NOT NULL,
license_expiry_date DATE NOT NULL,
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
vehicle_types_authorized VARCHAR[],
address TEXT,
emergency_contact VARCHAR,
notes TEXT,
total_trips INTEGER DEFAULT 0,
rating NUMERIC(3, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
);
CREATE INDEX idx_drivers_license_number ON freight.drivers(license_number);
CREATE INDEX idx_drivers_email ON freight.drivers(email);
CREATE INDEX idx_drivers_phone_number ON freight.drivers(phone_number);
CREATE INDEX idx_drivers_status ON freight.drivers(status);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.drivers CASCADE;`);
}
}

View File

@@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
export class DriversController {
constructor(private readonly driversService: DriversService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new driver' })
create(@Body() createDriverDto: CreateDriverDto) {
return this.driversService.create(createDriverDto);
}
@Get()
@ApiOperation({ summary: 'Get all drivers with filters' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.driversService.findAll({
search,
status: status as any,
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get driver by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.findById(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a driver' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDriverDto: UpdateDriverDto,
) {
return this.driversService.update(id, updateDriverDto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a driver' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
@Module({
imports: [TypeOrmModule.forFeature([Driver])],
providers: [DriversService],
controllers: [DriversController],
exports: [DriversService],
})
export class DriversModule {}

View File

@@ -0,0 +1,125 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
@Injectable()
export class DriversService {
constructor(
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
) {}
async create(dto: CreateDriverDto): Promise<Driver> {
const existing = await this.driverRepo.findOne({
where: [
{ licenseNumber: dto.licenseNumber },
{ email: dto.email },
{ phoneNumber: dto.phoneNumber },
],
});
if (existing) {
if (existing.licenseNumber === dto.licenseNumber) {
throw new ConflictException(`Driver with license number ${dto.licenseNumber} already exists`);
}
if (existing.email === dto.email) {
throw new ConflictException(`Driver with email ${dto.email} already exists`);
}
if (existing.phoneNumber === dto.phoneNumber) {
throw new ConflictException(`Driver with phone number ${dto.phoneNumber} already exists`);
}
}
const driver = this.driverRepo.create(dto);
return this.driverRepo.save(driver);
}
async findAll(query: {
search?: string;
status?: DriverStatus | string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
} = {}): Promise<{ data: Driver[]; total: number; page: number; limit: number }> {
const page = query.page || 1;
const limit = query.limit || 10;
const skip = (page - 1) * limit;
let qb = this.driverRepo.createQueryBuilder('d');
if (query.search) {
qb = qb.where(
'd.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search',
{ search: `%${query.search}%` },
);
}
if (query.status) {
qb = qb.andWhere('d.status = :status', { status: query.status });
}
const sortBy = ['firstName', 'lastName', 'status', 'createdAt'].includes(query.sortBy ?? '')
? query.sortBy
: 'createdAt';
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
const [data, total] = await qb
.orderBy(`d.${sortBy}`, sortOrder as 'ASC' | 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(id: string): Promise<Driver> {
const driver = await this.driverRepo.findOne({ where: { id } });
if (!driver) {
throw new NotFoundException(`Driver ${id} not found`);
}
return driver;
}
async update(id: string, dto: UpdateDriverDto): Promise<Driver> {
const driver = await this.findById(id);
if (dto.licenseNumber && dto.licenseNumber !== driver.licenseNumber) {
const existing = await this.driverRepo.findOne({
where: { licenseNumber: dto.licenseNumber },
});
if (existing) {
throw new ConflictException(`Driver with license number ${dto.licenseNumber} already exists`);
}
}
if (dto.email && dto.email !== driver.email) {
const existing = await this.driverRepo.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException(`Driver with email ${dto.email} already exists`);
}
}
if (dto.phoneNumber && dto.phoneNumber !== driver.phoneNumber) {
const existing = await this.driverRepo.findOne({
where: { phoneNumber: dto.phoneNumber },
});
if (existing) {
throw new ConflictException(`Driver with phone number ${dto.phoneNumber} already exists`);
}
}
Object.assign(driver, dto);
return this.driverRepo.save(driver);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.driverRepo.softDelete(id);
}
}

View File

@@ -0,0 +1,45 @@
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
import { DriverStatus } from '../entities/driver.entity';
export class CreateDriverDto {
@IsString()
licenseNumber!: string;
@IsString()
firstName!: string;
@IsString()
lastName!: string;
@IsEmail()
email!: string;
@IsString()
phoneNumber!: string;
@IsDateString()
dateOfBirth!: string;
@IsDateString()
licenseExpiryDate!: string;
@IsEnum(DriverStatus)
status!: DriverStatus;
@IsOptional()
@IsArray()
@IsString({ each: true })
vehicleTypesAuthorized?: string[];
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
emergencyContact?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDriverDto } from './create-driver.dto';
export class UpdateDriverDto extends PartialType(CreateDriverDto) {}

View File

@@ -0,0 +1,58 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
export enum DriverStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
SUSPENDED = 'SUSPENDED',
ON_LEAVE = 'ON_LEAVE',
}
@Entity({ name: 'drivers', schema: 'freight' })
@Index(['licenseNumber'])
@Index(['email'])
@Index(['phoneNumber'])
@Index(['status'])
export class Driver extends BaseEntity {
@Column({ name: 'license_number', unique: true })
licenseNumber!: string;
@Column({ name: 'first_name' })
firstName!: string;
@Column({ name: 'last_name' })
lastName!: string;
@Column({ unique: true })
email!: string;
@Column({ name: 'phone_number', unique: true })
phoneNumber!: string;
@Column({ name: 'date_of_birth', type: 'date' })
dateOfBirth!: Date;
@Column({ name: 'license_expiry_date', type: 'date' })
licenseExpiryDate!: Date;
@Column({ type: 'varchar', default: DriverStatus.ACTIVE })
status!: DriverStatus;
@Column({ name: 'vehicle_types_authorized', type: 'varchar', array: true, nullable: true })
vehicleTypesAuthorized?: string[];
@Column({ type: 'text', nullable: true })
address!: string | null;
@Column({ name: 'emergency_contact', nullable: true })
emergencyContact!: string | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
@Column({ name: 'total_trips', type: 'int', default: 0 })
totalTrips!: number;
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
rating!: number | null;
}

View File

@@ -0,0 +1,32 @@
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
export class CreateVehicleDto {
@IsString()
plateNumber!: string;
@IsEnum(VehicleType)
vehicleType!: VehicleType;
@IsString()
manufacturer!: string;
@IsString()
model!: string;
@IsNumber()
year!: number;
@IsEnum(FuelType)
fuelType!: FuelType;
@IsNumber()
capacity!: number;
@IsEnum(VehicleStatus)
status!: VehicleStatus;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateVehicleDto } from './create-vehicle.dto';
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}

View File

@@ -0,0 +1,64 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
export enum VehicleType {
TRUCK = 'TRUCK',
VAN = 'VAN',
CAR = 'CAR',
BUS = 'BUS',
TRAILER = 'TRAILER',
TANKER = 'TANKER',
FLATBED = 'FLATBED',
}
export enum FuelType {
PETROL = 'PETROL',
DIESEL = 'DIESEL',
ELECTRIC = 'ELECTRIC',
HYBRID = 'HYBRID',
}
export enum VehicleStatus {
ACTIVE = 'ACTIVE',
MAINTENANCE = 'MAINTENANCE',
RETIRED = 'RETIRED',
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
}
@Entity({ name: 'vehicles', schema: 'freight' })
@Index(['plateNumber'])
@Index(['registrationNumber'])
@Index(['status'])
@Index(['vehicleType'])
@Index(['manufacturer'])
export class Vehicle extends BaseEntity {
@Column({ name: 'plate_number', unique: true })
plateNumber!: string;
@Column({ name: 'registration_number', unique: true })
registrationNumber!: string;
@Column({ name: 'vehicle_type', type: 'varchar' })
vehicleType!: VehicleType;
@Column()
manufacturer!: string;
@Column()
model!: string;
@Column()
year!: number;
@Column({ name: 'fuel_type', type: 'varchar' })
fuelType!: FuelType;
@Column()
capacity!: number;
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
status!: VehicleStatus;
@Column({ type: 'text', nullable: true })
description!: string | null;
}

View File

@@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
export class VehiclesController {
constructor(private readonly vehiclesService: VehiclesService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new vehicle' })
create(@Body() createVehicleDto: CreateVehicleDto) {
return this.vehiclesService.create(createVehicleDto);
}
@Get()
@ApiOperation({ summary: 'Get all vehicles with filters' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.vehiclesService.findAll({
search,
status: status as any,
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get vehicle by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.findById(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a vehicle' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateVehicleDto: UpdateVehicleDto,
) {
return this.vehiclesService.update(id, updateVehicleDto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a vehicle' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
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';
@Module({
imports: [TypeOrmModule.forFeature([Vehicle])],
providers: [VehiclesService],
controllers: [VehiclesController],
exports: [VehiclesService],
})
export class VehiclesModule {}

View File

@@ -69,8 +69,7 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
async createVehicle(vehicleData: any): Promise<Vehicle> {
const vehicle = this.repository.create(vehicleData);
const vehicles = await this.repository.save(vehicle);
return vehicles?.[0];
return await this.repository.save(vehicle);
}
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {

View File

@@ -0,0 +1,106 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
@Injectable()
export class VehiclesService {
constructor(
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
) {}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
const existing = await this.vehicleRepo.findOne({
where: { plateNumber: dto.plateNumber },
});
if (existing) {
throw new ConflictException(
`Vehicle with plate number ${dto.plateNumber} already exists`,
);
}
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
const vehicle = this.vehicleRepo.create({
...dto,
registrationNumber,
});
return this.vehicleRepo.save(vehicle);
}
async findAll(query: {
search?: string;
status?: VehicleStatus | string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
} = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
const page = query.page || 1;
const limit = query.limit || 10;
const skip = (page - 1) * limit;
let qb = this.vehicleRepo.createQueryBuilder('v');
if (query.search) {
qb = qb.where(
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
{ search: `%${query.search}%` },
);
}
if (query.status) {
qb = qb.andWhere('v.status = :status', { status: query.status });
}
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
query.sortBy ?? '',
)
? query.sortBy
: 'createdAt';
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
const [data, total] = await qb
.orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(id: string): Promise<Vehicle> {
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
if (!vehicle) {
throw new NotFoundException(`Vehicle ${id} not found`);
}
return vehicle;
}
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
const vehicle = await this.findById(id);
if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
const existing = await this.vehicleRepo.findOne({
where: { plateNumber: dto.plateNumber },
});
if (existing) {
throw new ConflictException(
`Vehicle with plate number ${dto.plateNumber} already exists`,
);
}
}
Object.assign(vehicle, dto);
return this.vehicleRepo.save(vehicle);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.vehicleRepo.softDelete(id);
}
}