This commit is contained in:
marshal
2026-07-01 20:55:17 +03:00
parent 612df8daff
commit 9c18d086d7
112 changed files with 5654 additions and 1370 deletions

View File

@@ -1,19 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { RouteStatus } from '../entities/route.entity';
export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
@IsOptional()
@IsNumber()
@Min(0)
distanceKm?: number;
}
export class CreateRouteDto {
@ApiProperty()
@IsString()
@MaxLength(120)
name!: string;
@ApiProperty({ type: [CreateRouteMilestoneDto] })
@IsArray()
@ArrayMinSize(2)
@@ -21,8 +33,8 @@ export class CreateRouteDto {
@Type(() => CreateRouteMilestoneDto)
milestones!: CreateRouteMilestoneDto[];
@ApiPropertyOptional()
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
status?: RouteStatus;
}

View File

@@ -1,16 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { RouteStatus } from '../entities/route.entity';
export class FilterRoutesDto {
@ApiPropertyOptional()
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isActive?: boolean;
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
status?: RouteStatus;
}

View File

@@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity {
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
/** Kilometres from the previous stop (0 for origin). */
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true })
distanceKm?: number | null;
}

View File

@@ -4,13 +4,11 @@ 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;
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
@Entity({ schema: 'freight', name: 'routes' })
@Index(['status'])
export class Route extends BaseEntity {
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@@ -25,9 +23,24 @@ export class Route extends BaseEntity {
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
status!: RouteStatus;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}
export function formatRouteLabel(route: {
originYard?: { code?: string; name?: string } | null;
destinationYard?: { code?: string; name?: string } | null;
}): string {
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
return `${origin}${dest}`;
}
export function totalRouteDistanceKm(
milestones: Array<{ distanceKm?: number | string | null }>,
): number {
return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0);
}

View File

@@ -1,12 +1,12 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, ILike } from 'typeorm';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } 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 { formatRouteLabel, Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
@Injectable()
@@ -16,11 +16,10 @@ export class RoutesService {
private readonly routesRepository: RoutesRepository,
) {}
findAll(filter: FilterRoutesDto): Promise<Route[]> {
return this.routesRepository.findAll({
async findAll(filter: FilterRoutesDto): Promise<Route[]> {
const routes = await this.routesRepository.findAll({
where: {
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
...(filter.status ? { status: filter.status } : {}),
},
relations: {
originYard: true,
@@ -28,10 +27,33 @@ export class RoutesService {
milestones: { yard: true },
},
order: {
name: 'ASC',
milestones: { sequenceNo: 'ASC' },
},
});
const sorted = [...routes].sort((a, b) =>
formatRouteLabel(a).localeCompare(formatRouteLabel(b)),
);
const query = filter.search?.trim().toLowerCase();
if (!query) return sorted;
return sorted.filter((route) => {
const haystack = [
formatRouteLabel(route),
route.originYard?.label,
route.originYard?.code,
route.destinationYard?.label,
route.destinationYard?.code,
...(route.milestones ?? []).map(
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}
async findById(id: string): Promise<Route> {
@@ -53,16 +75,14 @@ export class RoutesService {
}
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,
status: dto.status ?? 'AVAILABLE',
}),
);
@@ -72,6 +92,7 @@ export class RoutesService {
routeId: savedRoute.id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
distanceKm: milestone.distanceKm,
}),
),
);
@@ -85,20 +106,16 @@ export class RoutesService {
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,
destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(dto.status !== undefined ? { status: dto.status } : {}),
});
if (milestoneInput) {
@@ -109,6 +126,7 @@ export class RoutesService {
routeId: id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
distanceKm: milestone.distanceKm,
}),
),
);
@@ -120,7 +138,9 @@ export class RoutesService {
async deactivate(id: string): Promise<Route> {
await this.findById(id);
const updated = await this.routesRepository.update(id, { isActive: false });
const updated = await this.routesRepository.update(id, {
status: 'STOP_WORKING',
} as never);
if (!updated) {
throw new NotFoundException(`Route ${id} not found`);
@@ -129,27 +149,32 @@ export class RoutesService {
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 }>) {
private async validateMilestones(
milestones: Array<{ yardId: string; distanceKm?: number }>,
) {
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 normalized = milestones.map((milestone, index) => {
const distanceKm =
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
throw new BadRequestException(
`Enter segment KM for stop ${index + 1} (from previous yard).`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm,
};
});
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
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) {