add yard distances management to rule engine

- Introduced new yard distances resource with CRUD operations.
- Created migration for yard distances table with necessary constraints.
- Implemented service and repository for yard distances handling.
- Added controller for API endpoints to manage yard distances.
- Updated rule engine configuration to include yard distances.
- Enhanced rule engine resource page to support yard distance selection.
- Updated contracts and train builder pages to handle new yard distance logic.
- Added error handling utility for better error message extraction.
This commit is contained in:
Marshal
2026-07-21 08:50:50 +00:00
parent 1647681840
commit 603537a20b
45 changed files with 1275 additions and 227 deletions

View File

@@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
import { YardDistancesService } from '../services/yard-distances.service';
@ApiTags('yard-distances')
@Controller('yard-distances')
@ApiBearerAuth()
export class YardDistancesController {
constructor(private readonly service: YardDistancesService) {}
@Get()
@RuleEngineView('yard-distances')
@ApiOperation({ summary: 'List yard distances' })
findAll(@Query() query: ListYardDistancesQueryDto) {
return this.service.findAll(query);
}
@Get(':id')
@RuleEngineView('yard-distances')
@ApiOperation({ summary: 'Get a yard distance by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('yard-distances')
@ApiOperation({ summary: 'Create a yard distance' })
create(@Body() dto: CreateYardDistanceDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('yard-distances')
@ApiOperation({ summary: 'Update a yard distance' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('yard-distances')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a yard distance' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, IsUUID, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
export class CreateYardDistanceDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
fromYardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
toYardId!: string;
@ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 })
@Transform(toNumber)
@IsNumber()
@Min(0.01)
distanceKm!: number;
}

View File

@@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto {
sortBy?: string;
}
export class ListYardDistancesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Return only distances touching this yard.' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' })
@IsOptional()
@IsIn(['createdAt', 'distanceKm'])
sortBy?: string;
}
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
@IsOptional()

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateYardDistanceDto } from './create-yard-distance.dto';
export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {}

View File

@@ -0,0 +1,35 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* Configured rail distance between two yards. Route creation reads segment
* kilometres from here (symmetric: A→B serves B→A too) instead of taking
* them as free-text input — see RoutesService.validateMilestones.
*
* Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a
* soft-deleted pair can be re-created.
*/
@Entity({ schema: 'freight', name: 'yard_distances' })
@Index(['fromYardId'])
@Index(['toYardId'])
export class YardDistance extends BaseEntity {
@Column({ name: 'from_yard_id', type: 'uuid' })
fromYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'from_yard_id' })
fromYard?: Yard;
@Column({ name: 'to_yard_id', type: 'uuid' })
toYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'to_yard_id' })
toYard?: Yard;
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 })
distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm
}

View File

@@ -0,0 +1,17 @@
import { PaginatedResponse } from '@edr/types';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { YardDistance } from '../entities/yard-distance.entity';
export interface IYardDistancesRepository {
findById(id: string): Promise<YardDistance | null>;
/** Exact or reverse pair — distances are symmetric (A→B serves B→A). */
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null>;
/** All rows touching any of the given yards, for batch segment lookups. */
findTouchingYards(yardIds: string[]): Promise<YardDistance[]>;
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>>;
create(data: Partial<YardDistance>): Promise<YardDistance>;
update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null>;
softDelete(id: string): Promise<void>;
}
export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY');

View File

@@ -0,0 +1,87 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { Brackets, DataSource, In, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { YardDistance } from '../entities/yard-distance.entity';
import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface';
@Injectable()
export class YardDistancesRepository implements IYardDistancesRepository {
private readonly repo: Repository<YardDistance>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(YardDistance);
}
findById(id: string): Promise<YardDistance | null> {
return this.repo.findOne({
where: { id },
relations: { fromYard: true, toYard: true },
});
}
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null> {
return this.repo.findOne({
where: [
{ fromYardId, toYardId },
{ fromYardId: toYardId, toYardId: fromYardId },
],
});
}
findTouchingYards(yardIds: string[]): Promise<YardDistance[]> {
if (!yardIds.length) return Promise.resolve([]);
return this.repo.find({
where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }],
});
}
/** Paged list with server-side search on either yard's label/code. */
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>> {
const qb = this.repo
.createQueryBuilder('yardDistance')
.leftJoinAndSelect('yardDistance.fromYard', 'fromYard')
.leftJoinAndSelect('yardDistance.toYard', 'toYard')
.orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC')
.addOrderBy('fromYard.label', 'ASC');
if (query.yardId) {
qb.andWhere(
new Brackets((w) =>
w
.where('yardDistance.fromYardId = :yardId', { yardId: query.yardId })
.orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }),
),
);
}
if (query.search) {
qb.andWhere(
new Brackets((w) =>
w
.where('fromYard.label ILIKE :search', { search: `%${query.search}%` })
.orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` })
.orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` })
.orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }),
),
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<YardDistance>): Promise<YardDistance> {
const entity = this.repo.create(data);
const saved = await this.repo.save(entity);
return (await this.findById(saved.id)) ?? saved;
}
async update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { YardDistancesController } from './controllers/yard-distances.controller';
import { YardsController } from './controllers/yards.controller';
import { ApprovalRule } from './entities/approval-rule.entity';
@@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
import { YardDistance } from './entities/yard-distance.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
@@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface';
import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
import { ApprovalRulesRepository } from './repositories/approval-rules.repository';
@@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { YardDistancesRepository } from './repositories/yard-distances.repository';
import { YardsRepository } from './repositories/yards.repository';
import { ApprovalRulesService } from './services/approval-rules.service';
@@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
import { YardDistancesService } from './services/yard-distances.service';
import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service';
@@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceType,
WeightLimitRule,
Yard,
YardDistance,
YardFacility,
ShippingLine,
Rate,
@@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesController,
WeightLimitRulesController,
YardsController,
YardDistancesController,
ShippingLinesController,
RatesController,
ApprovalRulesController,
@@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
{ provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository },
YardsRepository,
{ provide: YARDS_REPOSITORY, useExisting: YardsRepository },
YardDistancesRepository,
{ provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository },
ShippingLinesRepository,
{ provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository },
RatesRepository,
@@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesService,
WeightLimitRulesService,
YardsService,
YardDistancesService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
@@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
WeightLimitRulesService,
PriorityConfigsService,
YardsService,
YardDistancesService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
@@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
SERVICE_TYPES_REPOSITORY,
SHIPPING_LINES_REPOSITORY,
YARDS_REPOSITORY,
YARD_DISTANCES_REPOSITORY,
],
})
export class RuleEngineModule {}

View File

@@ -0,0 +1,119 @@
import { PaginatedResponse } from '@edr/types';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
import { YardDistance } from '../entities/yard-distance.entity';
import {
IYardDistancesRepository,
YARD_DISTANCES_REPOSITORY,
} from '../interfaces/yard-distances.repository.interface';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
/**
* Flat row shape for the backoffice config table: the yard relations stay for
* API consumers, plus label fields the generic rule-engine grid can render.
*/
export type YardDistanceRow = YardDistance & {
fromYardLabel: string;
toYardLabel: string;
};
const yardDisplay = (yard?: { label?: string; code?: string } | null): string =>
yard?.label ?? yard?.code ?? '—';
const toRow = (entity: YardDistance): YardDistanceRow =>
Object.assign(entity, {
fromYardLabel: yardDisplay(entity.fromYard),
toYardLabel: yardDisplay(entity.toYard),
});
@Injectable()
export class YardDistancesService {
constructor(
@Inject(YARD_DISTANCES_REPOSITORY)
private readonly repository: IYardDistancesRepository,
@Inject(YARDS_REPOSITORY)
private readonly yardsRepository: IYardsRepository,
) {}
async findAll(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistanceRow>> {
const page = await this.repository.findPaged(query);
return { ...page, items: page.items.map(toRow) };
}
async findById(id: string): Promise<YardDistanceRow> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Yard distance ${id} not found`);
return toRow(entity);
}
async create(dto: CreateYardDistanceDto): Promise<YardDistanceRow> {
await this.assertValidPair(dto.fromYardId, dto.toYardId);
const created = await this.repository.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
distanceKm: dto.distanceKm.toFixed(2),
});
return toRow(created);
}
async update(id: string, dto: UpdateYardDistanceDto): Promise<YardDistanceRow> {
const existing = await this.findById(id);
const fromYardId = dto.fromYardId ?? existing.fromYardId;
const toYardId = dto.toYardId ?? existing.toYardId;
if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) {
await this.assertValidPair(fromYardId, toYardId, id);
}
const updated = await this.repository.update(id, {
fromYardId,
toYardId,
...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}),
});
if (!updated) throw new NotFoundException(`Yard distance ${id} not found`);
return toRow(updated);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
/**
* Both yards must exist and differ, and the pair must not already be
* configured in either direction — distances are symmetric, so an A→B row
* already covers B→A.
*/
private async assertValidPair(
fromYardId: string,
toYardId: string,
ignoreId?: string,
): Promise<void> {
if (fromYardId === toYardId) {
throw new BadRequestException('From and to yards must be different');
}
const [fromYard, toYard] = await Promise.all([
this.yardsRepository.findById(fromYardId),
this.yardsRepository.findById(toYardId),
]);
if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`);
if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`);
const existing = await this.repository.findBetween(fromYardId, toYardId);
if (existing && existing.id !== ignoreId) {
throw new ConflictException(
`A distance between ${fromYard.label} and ${toYard.label} is already configured`,
);
}
}
}