feat(freight): added routes and locomotives

This commit is contained in:
Michael Abebe
2026-06-06 16:23:48 +03:00
parent e067da29df
commit a9c2f2eb97
32 changed files with 1443 additions and 64 deletions

View File

@@ -0,0 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
}
export class CreateRouteDto {
@ApiProperty()
@IsString()
@MaxLength(120)
name!: string;
@ApiProperty({ type: [CreateRouteMilestoneDto] })
@IsArray()
@ArrayMinSize(2)
@ValidateNested({ each: true })
@Type(() => CreateRouteMilestoneDto)
milestones!: CreateRouteMilestoneDto[];
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class FilterRoutesDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRouteDto } from './create-route.dto';
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from './route.entity';
@Entity({ schema: 'freight', name: 'route_milestones' })
@Index(['routeId', 'sequenceNo'], { unique: true })
export class RouteMilestone extends BaseEntity {
@Column({ name: 'route_id', type: 'uuid' })
routeId!: string;
@ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'route_id' })
route?: Route;
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { RouteMilestone } from './route-milestone.entity';
@Entity({ schema: 'freight', name: 'routes' })
@Index(['name'])
@Index(['isActive'])
export class Route extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
name!: string;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}

View File

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

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RoutesService } from './routes.service';
@ApiTags('routes')
@ApiBearerAuth()
@Controller('routes')
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}
@Get()
@ApiOperation({ summary: 'List routes' })
findAll(@Query() filter: FilterRoutesDto) {
return this.routesService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get route by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create route' })
create(@Body() dto: CreateRouteDto) {
return this.routesService.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update route' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
return this.routesService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Deactivate route' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.deactivate(id);
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
import { RouteMilestone } from './entities/route-milestone.entity';
import { Route } from './entities/route.entity';
import { RouteMilestonesRepository } from './route-milestones.repository';
import { RoutesController } from './routes.controller';
import { RoutesRepository } from './routes.repository';
import { RoutesService } from './routes.service';
@Module({
imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])],
controllers: [RoutesController],
providers: [RoutesRepository, RouteMilestonesRepository, RoutesService],
exports: [RoutesRepository, RouteMilestonesRepository, RoutesService],
})
export class RoutesModule {}

View File

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

View File

@@ -0,0 +1,171 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, ILike } from 'typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity';
import { Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
@Injectable()
export class RoutesService {
constructor(
private readonly dataSource: DataSource,
private readonly routesRepository: RoutesRepository,
) {}
findAll(filter: FilterRoutesDto): Promise<Route[]> {
return this.routesRepository.findAll({
where: {
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
},
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: {
name: 'ASC',
milestones: { sequenceNo: 'ASC' },
},
});
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: { milestones: { sequenceNo: 'ASC' } },
});
if (!route) {
throw new NotFoundException(`Route ${id} not found`);
}
return route;
}
async create(dto: CreateRouteDto): Promise<Route> {
await this.validateRouteName(dto.name);
const validated = await this.validateMilestones(dto.milestones);
const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save(
manager.getRepository(Route).create({
name: dto.name.trim(),
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
isActive: dto.isActive ?? true,
}),
);
await manager.getRepository(RouteMilestone).save(
validated.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: savedRoute.id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
}),
),
);
return savedRoute;
});
return this.findById(route.id);
}
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
const existing = await this.findById(id);
if (dto.name && dto.name.trim() !== existing.name) {
await this.validateRouteName(dto.name, id);
}
const milestoneInput = dto.milestones
? await this.validateMilestones(dto.milestones)
: null;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Route).update(id, {
name: dto.name?.trim() ?? existing.name,
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
isActive: dto.isActive ?? existing.isActive,
});
if (milestoneInput) {
await manager.getRepository(RouteMilestone).delete({ routeId: id });
await manager.getRepository(RouteMilestone).save(
milestoneInput.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
}),
),
);
}
});
return this.findById(id);
}
async deactivate(id: string): Promise<Route> {
await this.findById(id);
const updated = await this.routesRepository.update(id, { isActive: false });
if (!updated) {
throw new NotFoundException(`Route ${id} not found`);
}
return this.findById(id);
}
private async validateRouteName(name: string, routeId?: string) {
const trimmedName = name.trim();
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
if (existing && existing.id !== routeId) {
throw new ConflictException(`Route name ${trimmedName} already exists`);
}
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');
}
const normalized = milestones.map((milestone, index) => ({
yardId: milestone.yardId,
sequenceNo: index + 1,
}));
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
const yardIds = new Set(yards.map((yard) => yard.id));
for (const milestone of normalized) {
if (!yardIds.has(milestone.yardId)) {
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
}
}
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
throw new BadRequestException('Origin and destination yards must be different');
}
return {
originYardId: normalized[0].yardId,
destinationYardId: normalized[normalized.length - 1].yardId,
milestones: normalized,
};
}
}