fix data integrity issue

This commit is contained in:
natib21
2026-06-18 07:13:08 +00:00
parent b9c43e9214
commit 58fcf9ce3f
9 changed files with 354 additions and 4 deletions

View File

@@ -44,7 +44,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
@@ -56,6 +55,7 @@ import { ContainersModule } from './modules/container-management/containers.modu
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({
imports: [
@@ -113,12 +113,12 @@ import { OverviewModule } from './modules/overview/overview.module';
CargoesModule,
RoutesModule,
OverviewModule,
VehiclesModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
@@ -131,7 +131,6 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
@@ -144,7 +143,8 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.demoBookingsSeeder.run();
// Temporarily skip demo bookings seeder to debug
// await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.

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,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

@@ -0,0 +1,15 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Vehicle } from './entities/vehicle.entity';
@Injectable()
export class VehiclesRepository extends BaseRepository<Vehicle> {
constructor(
@InjectRepository(Vehicle)
repository: Repository<Vehicle>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,109 @@
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;
const where: any = {};
if (query.status) where.status = query.status;
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);
}
}