automation of loading and unloading

This commit is contained in:
Hagernesh
2026-06-17 22:33:06 +00:00
1637 changed files with 375027 additions and 20437 deletions

View File

@@ -1,4 +1,5 @@
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator';
import { WagonStatus } from '@edr/types';
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator';
export class CreateWagonDto {
@IsString()
@@ -16,10 +17,6 @@ export class CreateWagonDto {
@Min(1)
sequenceNumber?: number;
@IsOptional()
@IsUUID()
currentLocationYardId?: string;
@IsNumber()
@Min(0)
tareWeight!: number;
@@ -29,8 +26,12 @@ export class CreateWagonDto {
maxPayloadWeight!: number;
@IsOptional()
@IsIn(['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED', 'MAINTENANCE', 'RETIRED'])
status?: string;
@IsEnum(WagonStatus)
status?: WagonStatus;
@IsOptional()
@IsUUID()
currentYardId?: string;
@IsOptional()
@IsString()

View File

@@ -0,0 +1,56 @@
import { WagonStatus } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class ListWagonsQueryDto {
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: WagonStatus })
@IsOptional()
@IsEnum(WagonStatus)
status?: WagonStatus;
@ApiPropertyOptional({ description: 'Filter by current yard' })
@IsOptional()
@IsUUID()
currentYardId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
wagonTypeId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
trainId?: string;
@ApiPropertyOptional({ default: 'wagonNumber' })
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' })
@IsOptional()
@IsString()
sortOrder?: 'ASC' | 'DESC';
@ApiPropertyOptional({ minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
}

View File

@@ -1,12 +1,24 @@
// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { WagonStatus } from '@edr/types';
import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { Train } from '../../trains/entities/train.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { Container } from '../../container-management/entities/container.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
export const WAGON_STATUSES = [
WagonStatus.Available,
WagonStatus.Assigned,
WagonStatus.Maintenance,
WagonStatus.Retired,
] as const;
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
@Entity({ name: 'wagons', schema: 'freight' })
@Index(['currentYardId'])
export class Wagon extends BaseEntity {
@Column({ unique: true, name: 'wagon_number' })
wagonNumber!: string;
@@ -14,36 +26,46 @@ export class Wagon extends BaseEntity {
@Column({ name: 'wagon_type_id', type: 'uuid' })
wagonTypeId!: string;
@ManyToOne(() => WagonType, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType;
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId!: string | null;
@Column({ name: 'sequence_number', type: 'int', nullable: true })
sequenceNumber!: number | null;
@Column({ name: 'current_location_yard_id', type: 'uuid', nullable: true })
currentLocationYardId!: string | null;
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'current_location_yard_id' })
currentLocationYard?: Yard | null;
@Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
tareWeight!: number;
@Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
maxPayloadWeight!: number;
@Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, IMPORT_READY, EXPORT_READY, ASSIGNED, MAINTENANCE, RETIRED
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
status!: WagonStatusType;
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
currentYardId!: string | null;
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'current_yard_id' })
currentYard?: Yard | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
// Relationship to Train
@Column({ name: 'train_set_wagon_id', type: 'uuid', nullable: true })
trainSetWagonId!: string | null;
@ManyToOne(() => TrainSetWagon, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'train_set_wagon_id' })
trainSetWagon?: TrainSetWagon | null;
@Column({ name: 'current_train_schedule_id', type: 'uuid', nullable: true })
currentTrainScheduleId!: string | null;
@ManyToOne(() => TrainSchedule, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'current_train_schedule_id' })
currentTrainSchedule?: TrainSchedule | null;
/** Fleet master consist grouping — separate from operational train_schedules. */
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'train_id' })
train!: Train | null;
@@ -51,4 +73,4 @@ export class Wagon extends BaseEntity {
// Relationship to Container
@OneToMany(() => Container, (container) => container.wagon)
containers!: Container[];
}
}

View File

@@ -10,7 +10,9 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
@@ -18,10 +20,12 @@ import { WagonsService } from './wagons.service';
@ApiTags('wagons')
@Controller('wagons')
@FleetView()
export class WagonsController {
constructor(private readonly wagonsService: WagonsService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new wagon' })
create(@Body() dto: CreateWagonDto) {
return this.wagonsService.create(dto);
@@ -29,7 +33,7 @@ export class WagonsController {
@Get()
@ApiOperation({ summary: 'List all wagons' })
findAll(@Query() query: Record<string, string | undefined>) {
findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query);
}
@@ -40,24 +44,28 @@ export class WagonsController {
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a wagon' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
return this.wagonsService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a wagon' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.remove(id);
}
@Post(':id/assign-train')
@FleetManage()
@ApiOperation({ summary: 'Assign wagon to a train' })
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
return this.wagonsService.assignToTrain(id, dto);
}
@Post(':id/unassign-train')
@FleetManage()
@ApiOperation({ summary: 'Unassign wagon from train' })
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id);
@@ -66,10 +74,12 @@ export class WagonsController {
// Separate controller for trainspecific reorder (registered in module)
@Controller('trains/:trainId/reorder-wagons')
@FleetView()
export class TrainWagonsReorderController {
constructor(private readonly wagonsService: WagonsService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Reorder wagons of a train' })
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
return this.wagonsService.reorderWagons(trainId, dto);

View File

@@ -1,13 +1,14 @@
import { WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { Wagon } from './entities/wagon.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@Injectable()
export class WagonsService {
@@ -16,44 +17,48 @@ export class WagonsService {
private readonly wagonRepo: Repository<Wagon>,
@InjectRepository(Train)
private readonly trainRepo: Repository<Train>,
@InjectRepository(Yard)
private readonly yardRepo: Repository<Yard>,
private readonly dataSource: DataSource,
) {}
async create(dto: CreateWagonDto): Promise<Wagon> {
const wagon = this.wagonRepo.create(dto);
const wagon = this.wagonRepo.create({
...dto,
status: dto.status ?? WagonStatus.Available,
});
// Convert undefined to null for nullable fields
if (dto.trainId === undefined) wagon.trainId = null;
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
wagon.status = await this.statusForLocation(dto.currentLocationYardId, dto.status);
if (dto.currentYardId === undefined) wagon.currentYardId = null;
return this.wagonRepo.save(wagon);
}
async findAll(query: Record<string, string | undefined> = {}): Promise<Wagon[]> {
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
const search = query.search?.trim();
const status = query.status?.trim();
const trainId = query.trainId?.trim();
const currentLocationYardId = query.currentLocationYardId?.trim();
const wagonTypeId = query.wagonTypeId?.trim();
const filters: FindOptionsWhere<Wagon> = {
...(query.status ? { status: query.status } : {}),
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
...(trainId ? { trainId } : {}),
...(wagonTypeId ? { wagonTypeId } : {}),
};
if (search) {
where.push({
wagonNumber: ILike(`%${search}%`),
...(status ? { status } : {}),
...(trainId ? { trainId } : {}),
...(currentLocationYardId ? { currentLocationYardId } : {}),
...filters,
});
}
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'sequenceNumber'].includes(query.sortBy ?? '')
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '')
? (query.sortBy as keyof Wagon)
: 'wagonNumber';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.wagonRepo.find({
where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}), ...(currentLocationYardId ? { currentLocationYardId } : {}) },
relations: { currentLocationYard: true, wagonType: true },
where: search ? where : filters,
relations: { currentYard: true },
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? Number(query.limit) : undefined,
@@ -61,7 +66,10 @@ export class WagonsService {
}
async findById(id: string): Promise<Wagon> {
const wagon = await this.wagonRepo.findOne({ where: { id }, relations: { currentLocationYard: true, wagonType: true } });
const wagon = await this.wagonRepo.findOne({
where: { id },
relations: { currentYard: true },
});
if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
return wagon;
}
@@ -69,9 +77,6 @@ export class WagonsService {
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
const wagon = await this.findById(id);
Object.assign(wagon, dto);
if (dto.currentLocationYardId !== undefined) {
wagon.status = await this.statusForLocation(dto.currentLocationYardId, dto.status);
}
return this.wagonRepo.save(wagon);
}
@@ -82,7 +87,7 @@ export class WagonsService {
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
const wagon = await this.findById(wagonId);
if (wagon.status === 'ASSIGNED') {
if (wagon.status === WagonStatus.Assigned) {
throw new ConflictException('Wagon already assigned to a train');
}
@@ -101,7 +106,7 @@ export class WagonsService {
wagon.trainId = train.id;
wagon.sequenceNumber = sequence;
wagon.status = 'ASSIGNED';
wagon.status = WagonStatus.Assigned;
return this.wagonRepo.save(wagon);
}
@@ -109,21 +114,10 @@ export class WagonsService {
const wagon = await this.findById(wagonId);
wagon.trainId = null;
wagon.sequenceNumber = null;
wagon.status = await this.statusForLocation(wagon.currentLocationYardId, 'AVAILABLE');
wagon.status = WagonStatus.Available;
return this.wagonRepo.save(wagon);
}
private async statusForLocation(yardId?: string | null, fallback = 'AVAILABLE') {
if (!yardId) return fallback;
const yard = await this.yardRepo.findOne({ where: { id: yardId } });
const country = yard?.country?.trim().toLowerCase();
if (country === 'ethiopia' || country === 'et') return 'EXPORT_READY';
if (country === 'djibouti' || country === 'djoubti' || country === 'dj') return 'IMPORT_READY';
return fallback;
}
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();