mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
feat(freight): added routes and locomotives
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateRouteDto } from './create-route.dto';
|
||||
|
||||
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal file
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal file
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal 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 {}
|
||||
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal file
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal file
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user