mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Merge pull request #672 from Tria-plc/freight_feature/usermanagement
train
This commit is contained in:
@@ -1255,10 +1255,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
destinationYard: true,
|
||||
// units carry the real per-container numbers entered at booking time —
|
||||
// the wagon plan shows those instead of generated placeholders.
|
||||
// containerType.wagonType + cargoType.wagonType drive wagon-type
|
||||
// resolution during scheduling (FK, not the old load-type string map).
|
||||
bookingContainers: { containerType: { wagonType: true }, units: true },
|
||||
cargoType: { wagonType: true },
|
||||
// containerType.wagonTypes + cargoType.wagonTypes drive wagon-type
|
||||
// resolution during scheduling (many-to-many lists — the plan mixes
|
||||
// wagon types within one consist).
|
||||
bookingContainers: { containerType: { wagonTypes: true }, units: true },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateCargoTypeDto {
|
||||
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
||||
@@ -22,12 +22,15 @@ export class CreateCargoTypeDto {
|
||||
parentGroupId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
|
||||
'Wagon types that can carry this (bulk) cargo. Drives train scheduling wagon-type resolution; at least one is required for bulk commodities that are scheduled.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
wagonTypeId?: string | null;
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonTypeIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateContainerTypeDto {
|
||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||
@@ -31,12 +31,15 @@ export class CreateContainerTypeDto {
|
||||
isOpenTop?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
|
||||
'Wagon types that can carry this container. Drives train scheduling wagon-type resolution; at least one is required when this container type is scheduled.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
wagonTypeId?: string | null;
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonTypeIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'cargo_types' })
|
||||
@Index(['isActive'])
|
||||
@Index(['displayOrder'])
|
||||
@Index(['parentGroupId'])
|
||||
@Index(['wagonTypeId'])
|
||||
@Index(['code'])
|
||||
export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
|
||||
@@ -28,17 +36,19 @@ export class CargoType extends BaseEntity {
|
||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||
|
||||
/**
|
||||
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
|
||||
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
|
||||
* through this FK. Nullable — grouping rows and container/legacy cargo never
|
||||
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
|
||||
* Wagon types that can carry this (bulk) cargo. Train scheduling resolves the
|
||||
* bulk wagon type through this list, picking whichever type the schedule's
|
||||
* train (or yard) actually has. Grouping rows and container/legacy cargo
|
||||
* leave it empty; scheduling throws if a scheduled bulk cargo type has none.
|
||||
*/
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||
wagonTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType | null;
|
||||
@ManyToMany(() => WagonType)
|
||||
@JoinTable({
|
||||
name: 'cargo_type_wagon_types',
|
||||
schema: 'freight',
|
||||
joinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' },
|
||||
})
|
||||
wagonTypes?: WagonType[];
|
||||
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinTable, ManyToMany, OneToMany } from 'typeorm';
|
||||
import { WeightLimitRule } from './weight-limit-rule.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'container_types' })
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
@Index(['wagonTypeId'])
|
||||
export class ContainerType extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||
code!: string;
|
||||
@@ -27,17 +26,19 @@ export class ContainerType extends BaseEntity {
|
||||
isOpenTop!: boolean;
|
||||
|
||||
/**
|
||||
* Wagon type that carries this container. Replaces the former hardcoded
|
||||
* container wagon-code default (NW5): train scheduling resolves the container
|
||||
* wagon type through this FK. Nullable; scheduling throws if a scheduled
|
||||
* container type leaves it unset.
|
||||
* Wagon types that can carry this container (e.g. a 20ft rides NX70 or NW5).
|
||||
* Train scheduling resolves the container wagon type through this list,
|
||||
* picking whichever type the schedule's train (or yard) actually has.
|
||||
* Scheduling throws if a scheduled container type has none configured.
|
||||
*/
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||
wagonTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType | null;
|
||||
@ManyToMany(() => WagonType)
|
||||
@JoinTable({
|
||||
name: 'container_type_wagon_types',
|
||||
schema: 'freight',
|
||||
joinColumn: { name: 'container_type_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' },
|
||||
})
|
||||
wagonTypes?: WagonType[];
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
}
|
||||
|
||||
findById(id: string): Promise<CargoType | null> {
|
||||
return this.repo.findOne({ where: { id }, relations: { parent: true } });
|
||||
return this.repo.findOne({ where: { id }, relations: { parent: true, wagonTypes: true } });
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<CargoType | null> {
|
||||
@@ -35,6 +35,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('cargoType')
|
||||
.leftJoinAndSelect('cargoType.parent', 'parent')
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'wagonType')
|
||||
.orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
@@ -63,7 +64,18 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
|
||||
await this.repo.update(id, data as never);
|
||||
// Relation lists can't ride a column UPDATE — sync them via entity save.
|
||||
const { wagonTypes, ...columns } = data;
|
||||
if (Object.keys(columns).length) {
|
||||
await this.repo.update(id, columns as never);
|
||||
}
|
||||
if (wagonTypes) {
|
||||
const entity = await this.repo.findOne({ where: { id } });
|
||||
if (entity) {
|
||||
entity.wagonTypes = wagonTypes;
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
}
|
||||
|
||||
findById(id: string): Promise<ContainerType | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
return this.repo.findOne({ where: { id }, relations: { wagonTypes: true } });
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<ContainerType | null> {
|
||||
@@ -34,6 +34,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('containerType')
|
||||
.leftJoinAndSelect('containerType.wagonTypes', 'wagonType')
|
||||
.orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
@@ -54,7 +55,18 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
|
||||
await this.repo.update(id, data as never);
|
||||
// Relation lists can't ride a column UPDATE — sync them via entity save.
|
||||
const { wagonTypes, ...columns } = data;
|
||||
if (Object.keys(columns).length) {
|
||||
await this.repo.update(id, columns as never);
|
||||
}
|
||||
if (wagonTypes) {
|
||||
const entity = await this.repo.findOne({ where: { id } });
|
||||
if (entity) {
|
||||
entity.wagonTypes = wagonTypes;
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||
import { CargoType } from '../entities/cargo-type.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
@@ -59,7 +60,8 @@ export class CargoTypesService {
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
wagonTypeId: dto.wagonTypeId ?? null,
|
||||
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
|
||||
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
@@ -72,7 +74,13 @@ export class CargoTypesService {
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
const updated = await this.repository.update(id, dto);
|
||||
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
|
||||
const updated = await this.repository.update(id, {
|
||||
...columns,
|
||||
...(wagonTypeIds
|
||||
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
|
||||
: {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||
import { ContainerType } from '../entities/container-type.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
CONTAINER_TYPES_REPOSITORY,
|
||||
IContainerTypesRepository,
|
||||
@@ -51,7 +52,8 @@ export class ContainerTypesService {
|
||||
isReefer: dto.isReefer ?? false,
|
||||
isOpenTop: dto.isOpenTop ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
wagonTypeId: dto.wagonTypeId ?? null,
|
||||
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
|
||||
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
@@ -59,7 +61,13 @@ export class ContainerTypesService {
|
||||
/** Update an existing container type. */
|
||||
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
|
||||
await this.findById(id);
|
||||
const updated = await this.repository.update(id, dto);
|
||||
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
|
||||
const updated = await this.repository.update(id, {
|
||||
...columns,
|
||||
...(wagonTypeIds
|
||||
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
|
||||
: {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
train: true,
|
||||
wagons: {
|
||||
wagonType: true,
|
||||
physicalWagon: true,
|
||||
|
||||
@@ -886,7 +886,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
};
|
||||
|
||||
it('charges a bulk booking the tare of ITS wagon type, not the representative', () => {
|
||||
const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } });
|
||||
const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } });
|
||||
const need = service.needFor(booking, dimsWithTypes);
|
||||
expect(need.wagons).toBe(30);
|
||||
expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation
|
||||
@@ -905,7 +905,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
{
|
||||
quantity: 2,
|
||||
wagonsRequired: 2,
|
||||
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' },
|
||||
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2913,21 +2913,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Dimensions of the wagon type THIS booking rides: bulk resolves through its
|
||||
* cargo type's wagon_type_id, container through the first container line's
|
||||
* type — the same FK resolution `resolveWagonType` applies when the paid
|
||||
* booking is allocated. Board/fill math measured on a representative wagon
|
||||
* while allocation validated the real one let a selected batch flunk the
|
||||
* post-payment gross-weight check; sharing the resolution closes that gap.
|
||||
* Falls back to the representative dims when the FK or relation is absent.
|
||||
* cargo type's allowed wagon-type list, container through the first container
|
||||
* line's — the same list resolution the scheduling planner applies when the
|
||||
* paid booking is allocated. Board/fill math measured on a representative
|
||||
* wagon while allocation validated the real one let a selected batch flunk
|
||||
* the post-payment gross-weight check; sharing the resolution closes that
|
||||
* gap. Uses the first configured type (the fill engine has no train context);
|
||||
* falls back to the representative dims when the list or relation is absent.
|
||||
*/
|
||||
private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims {
|
||||
const fallback =
|
||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||
const wagonTypeId =
|
||||
booking.freightType === "BULK"
|
||||
? booking.cargoType?.wagonTypeId
|
||||
? booking.cargoType?.wagonTypes?.[0]?.id
|
||||
: (booking.bookingContainers ?? [])
|
||||
.map((line) => line.containerType?.wagonTypeId)
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wagonType) => wagonType.id)
|
||||
.find((id): id is string => Boolean(id));
|
||||
const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined;
|
||||
if (!dims) return fallback;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableTrainsQueryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
@@ -20,15 +20,26 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Built train (Train Builder) to run this departure — its locomotive set is used. Provide either trainId or locomotiveIds.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Locomotives pulling the train (minimum 2 — front and back)',
|
||||
description:
|
||||
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
locomotiveIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from "./dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
|
||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
|
||||
@@ -153,6 +154,18 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("available-trains")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List built trains (Train Builder) schedulable on a route, annotated with yard position and future runs",
|
||||
})
|
||||
getAvailableTrains(@Query() query: AvailableTrainsQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableTrainsForRoute(
|
||||
query.routeId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||
|
||||
@@ -72,7 +72,7 @@ const makeBooking = (
|
||||
wagonsRequired,
|
||||
vgmPerUnitTons: weight / quantity,
|
||||
isOverweight: false,
|
||||
containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id },
|
||||
containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] },
|
||||
},
|
||||
],
|
||||
...extra,
|
||||
@@ -80,7 +80,7 @@ const makeBooking = (
|
||||
|
||||
describe('TrainSchedulingService', () => {
|
||||
let service: TrainSchedulingService;
|
||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
|
||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
|
||||
let bookingsRepository: Record<string, jest.Mock>;
|
||||
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let wagonTypesRepository: { findAll: jest.Mock };
|
||||
@@ -91,7 +91,12 @@ describe('TrainSchedulingService', () => {
|
||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
|
||||
dataSource = {
|
||||
getRepository: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
findByIdsForScheduling: jest.fn(),
|
||||
@@ -259,8 +264,10 @@ describe('TrainSchedulingService', () => {
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(result.summary.wagonsNeeded).toBe(45);
|
||||
expect(result.wagonPlan).toHaveLength(45);
|
||||
// TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) —
|
||||
// the planner packs by container size, not the stored per-line fallback.
|
||||
expect(result.summary.wagonsNeeded).toBe(40);
|
||||
expect(result.wagonPlan).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('returns soft hold warnings without forceAssign', async () => {
|
||||
@@ -293,7 +300,7 @@ describe('TrainSchedulingService', () => {
|
||||
wagonsRequired: 80,
|
||||
vgmPerUnitTons: 45,
|
||||
isOverweight: true,
|
||||
containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id },
|
||||
containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -655,10 +662,12 @@ describe('TrainSchedulingService', () => {
|
||||
destinationStationId: 'yard-djibouti',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
|
||||
).toBe(true);
|
||||
// List-based planner: a booking with no plannable wagon at the yard is
|
||||
// DEFERRED with the wagon-type reason (assign still hard-fails when no
|
||||
// booking fits), instead of surfacing a phantom-slot violation.
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.wagonPlan).toHaveLength(0);
|
||||
expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true);
|
||||
});
|
||||
|
||||
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
expandBookingContainerUnits,
|
||||
roundTons,
|
||||
tareTonsOf,
|
||||
teuSlotsForSizeFt,
|
||||
type SlotLoadType,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
|
||||
/**
|
||||
* Wagon types allowed to carry each container type / bulk cargo type — the
|
||||
* many-to-many configuration lists, resolved once per validation run.
|
||||
*/
|
||||
export type AllowedWagonTypeMap = {
|
||||
byContainerTypeId: Map<string, WagonType[]>;
|
||||
byCargoTypeId: Map<string, WagonType[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plannable wagon inventory. TRAIN mode is the built train's own consist —
|
||||
* a hard cap, the plan never reaches for loose yard wagons. YARD mode is the
|
||||
* AVAILABLE pool at the boarding yards (legacy schedules).
|
||||
*/
|
||||
export type WagonStock = {
|
||||
mode: 'TRAIN' | 'YARD';
|
||||
/** Remaining plannable wagons per wagon type id. Missing type = 0. */
|
||||
remainingByTypeId: Map<string, number>;
|
||||
/** Wagon-type code per id, for human-readable shortfall messages. */
|
||||
codesByTypeId: Map<string, string>;
|
||||
};
|
||||
|
||||
export type FlexPlanResult = {
|
||||
plan: WagonPlanSlot[];
|
||||
fitting: Booking[];
|
||||
deferred: DeferredBookingRow[];
|
||||
/**
|
||||
* Misconfiguration (a scheduled type with no wagon types configured) —
|
||||
* a hard violation, unlike stock shortfalls which merely defer bookings.
|
||||
*/
|
||||
configIssues: string[];
|
||||
};
|
||||
|
||||
type OpenSlot = {
|
||||
slot: WagonPlanSlot;
|
||||
teuUsed: number;
|
||||
kind: SlotLoadType;
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
};
|
||||
|
||||
type PlacementProblem = { kind: 'config' | 'stock'; message: string };
|
||||
|
||||
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
|
||||
sequenceNo: 0, // stamped at the end
|
||||
wagonTypeId: wagonType.id,
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: Number(wagonType.capacityTons),
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
slotLoadType: kind,
|
||||
});
|
||||
|
||||
const addAllocation = (
|
||||
slot: WagonPlanSlot,
|
||||
bookingId: string,
|
||||
bookingReference: string,
|
||||
weightTons: number,
|
||||
loadType: AllocationLoadType,
|
||||
) => {
|
||||
let allocation = slot.allocations.find((a) => a.bookingId === bookingId);
|
||||
if (!allocation) {
|
||||
allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType };
|
||||
slot.allocations.push(allocation);
|
||||
}
|
||||
allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons);
|
||||
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the wagon plan against a wagon-type inventory, mixing wagon types
|
||||
* within one consist. Each booking is atomic: it either fits entirely (its
|
||||
* containers/tonnage placed on wagons whose type is allowed for its container
|
||||
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
|
||||
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
|
||||
* two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon
|
||||
* with a different cargo type.
|
||||
*/
|
||||
export function planWagonsWithStock(params: {
|
||||
bookings: Booking[];
|
||||
allowed: AllowedWagonTypeMap;
|
||||
stock: WagonStock;
|
||||
}): FlexPlanResult {
|
||||
const { bookings, allowed, stock } = params;
|
||||
const remaining = new Map(stock.remainingByTypeId);
|
||||
const openSlots: OpenSlot[] = [];
|
||||
const fitting: Booking[] = [];
|
||||
const deferred: DeferredBookingRow[] = [];
|
||||
const configIssues = new Set<string>();
|
||||
|
||||
const noStockMessage = (candidates: WagonType[]): string => {
|
||||
const codes = candidates.map((wt) => wt.code).join('/');
|
||||
return stock.mode === 'TRAIN'
|
||||
? `Train has no free ${codes} wagon left`
|
||||
: `No available ${codes} wagon at the yard`;
|
||||
};
|
||||
|
||||
/** Open a new wagon of one of the candidate types, consuming stock. */
|
||||
const openSlot = (
|
||||
candidates: WagonType[],
|
||||
kind: SlotLoadType,
|
||||
cargoTypeId: string | null,
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
||||
if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
|
||||
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
|
||||
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
kind === 'BULK'
|
||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
|
||||
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
|
||||
)[0];
|
||||
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
|
||||
const open: OpenSlot = {
|
||||
slot: slotFromWagonType(chosen, kind),
|
||||
teuUsed: 0,
|
||||
kind,
|
||||
cargoTypeId,
|
||||
freeCapacityTons: Number(chosen.capacityTons),
|
||||
};
|
||||
openSlots.push(open);
|
||||
return open;
|
||||
};
|
||||
|
||||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
if (!units.length) {
|
||||
// Degenerate container booking with no lines still reserves one wagon
|
||||
// (legacy behavior) — but there is no container type to resolve against.
|
||||
return {
|
||||
kind: 'config',
|
||||
message: `Booking ${booking.reference} has no container lines to plan`,
|
||||
};
|
||||
}
|
||||
for (const unit of units) {
|
||||
const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? [];
|
||||
if (!candidates.length) {
|
||||
return {
|
||||
kind: 'config',
|
||||
message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`,
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||
let target = openSlots.find(
|
||||
(open) =>
|
||||
open.kind === 'CONTAINER' &&
|
||||
allowedIds.has(open.slot.wagonTypeId) &&
|
||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
||||
);
|
||||
if (!target) {
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
target = openedSlot;
|
||||
}
|
||||
addAllocation(
|
||||
target.slot,
|
||||
unit.bookingId,
|
||||
unit.bookingReference,
|
||||
unit.grossWeightTons,
|
||||
AllocationLoadType.Container,
|
||||
);
|
||||
target.teuUsed += teu;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// BULK — weight-based, one cargo type per wagon.
|
||||
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null;
|
||||
const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
|
||||
if (!candidates.length) {
|
||||
return {
|
||||
kind: 'config',
|
||||
message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`,
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
let placedAnywhere = false;
|
||||
|
||||
// Top off wagons already carrying THIS cargo type before opening new ones.
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
open.slot,
|
||||
booking.id,
|
||||
booking.reference,
|
||||
take,
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
openedSlot.slot,
|
||||
booking.id,
|
||||
booking.reference,
|
||||
take,
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take);
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||||
const remainingSnapshot = new Map(remaining);
|
||||
const slotCountSnapshot = openSlots.length;
|
||||
const slotStateSnapshot = openSlots.map((open) => ({
|
||||
teuUsed: open.teuUsed,
|
||||
freeCapacityTons: open.freeCapacityTons,
|
||||
assignedWeightTons: open.slot.assignedWeightTons,
|
||||
allocationCount: open.slot.allocations.length,
|
||||
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
||||
}));
|
||||
|
||||
const problem = tryPlaceBooking(booking);
|
||||
if (!problem) {
|
||||
fitting.push(booking);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Roll back this booking's partial placements.
|
||||
remaining.clear();
|
||||
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
|
||||
openSlots.length = slotCountSnapshot;
|
||||
openSlots.forEach((open, index) => {
|
||||
const snap = slotStateSnapshot[index];
|
||||
if (!snap) return;
|
||||
open.teuUsed = snap.teuUsed;
|
||||
open.freeCapacityTons = snap.freeCapacityTons;
|
||||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||||
open.slot.allocations.length = snap.allocationCount;
|
||||
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
||||
open.slot.allocations[allocationIndex].allocatedWeightTons = weight;
|
||||
});
|
||||
});
|
||||
|
||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||
deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
|
||||
}
|
||||
|
||||
return {
|
||||
plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })),
|
||||
fitting,
|
||||
deferred,
|
||||
configIssues: [...configIssues],
|
||||
};
|
||||
}
|
||||
|
||||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||||
export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock {
|
||||
const remainingByTypeId = new Map<string, number>();
|
||||
const codesByTypeId = new Map<string, string>();
|
||||
for (const list of [
|
||||
...allowed.byContainerTypeId.values(),
|
||||
...allowed.byCargoTypeId.values(),
|
||||
]) {
|
||||
for (const wagonType of list) {
|
||||
remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER);
|
||||
codesByTypeId.set(wagonType.id, wagonType.code);
|
||||
}
|
||||
}
|
||||
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||
}
|
||||
@@ -514,7 +514,7 @@ export function validateTrainLimits(
|
||||
*/
|
||||
export function validateMixedTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: WagonType[],
|
||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetLocomotive } from './train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from './train-set-wagon.entity';
|
||||
@@ -32,6 +33,14 @@ export class TrainSet extends BaseEntity {
|
||||
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
|
||||
locomotives?: TrainSetLocomotive[];
|
||||
|
||||
/** Built fleet train this set was formed from (Train Builder), when scheduled by train. */
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId!: string | null;
|
||||
|
||||
@ManyToOne(() => Train, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train?: Train | null;
|
||||
|
||||
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
totalWeightTons!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class AssignTrainWagonsDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Wagons to append to the consist, in order. Each must be AVAILABLE in the train\'s yard.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BuildTrainDto {
|
||||
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
|
||||
@IsUUID()
|
||||
currentYardId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Wagons to attach at build time, in consist order (must sit in the same yard)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 100 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
trainName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Freight } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class ListBuiltTrainsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: Freight.TrainStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(Freight.TrainStatus)
|
||||
status?: Freight.TrainStatus;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Only trains sitting in this yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['code', 'trainName', 'status', 'createdAt'] })
|
||||
@IsOptional()
|
||||
@IsIn(['code', 'trainName', 'status', 'createdAt'])
|
||||
sortBy?: 'code' | 'trainName' | 'status' | 'createdAt';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReorderTrainWagonsDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Every wagon of the train, in the new consist order',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateTrainLocomotivesDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Full replacement locomotive set (minimum 2), in consist order',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { Train } from './train.entity';
|
||||
|
||||
/**
|
||||
* Link row joining a built train to one of its locomotives. A train must be
|
||||
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
|
||||
* in the consist — 0 is the lead locomotive.
|
||||
*
|
||||
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built
|
||||
* in the Train Builder rather than the per-departure operational train set.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'train_locomotives' })
|
||||
@Index(['trainId', 'locomotiveId'], { unique: true })
|
||||
export class TrainLocomotive extends BaseEntity {
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@ManyToOne(() => Train, (train) => train.locomotives, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train?: Train;
|
||||
|
||||
@Column({ name: 'locomotive_id', type: 'uuid' })
|
||||
locomotiveId!: string;
|
||||
|
||||
@ManyToOne(() => Locomotive)
|
||||
@JoinColumn({ name: 'locomotive_id' })
|
||||
locomotive?: Locomotive;
|
||||
|
||||
@Column({ name: 'sequence_no', type: 'int', default: 0 })
|
||||
sequenceNo!: number;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Column, Entity, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { TrainLocomotive } from './train-locomotive.entity';
|
||||
|
||||
/**
|
||||
* Fleet master data — named wagon consist in inventory (POST /trains).
|
||||
* Operational departures use train_schedules + locomotives; scheduling never creates trains rows.
|
||||
* Fleet master data — a train built in the Train Builder: a coded consist
|
||||
* (e.g. 81001) of 2+ locomotives and ordered wagons, assembled in one yard.
|
||||
* Operational departures reference it through `train_sets.train_id`; the
|
||||
* schedule's own composition still lives on the train set.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'trains' })
|
||||
export class Train extends BaseEntity {
|
||||
@@ -56,7 +60,19 @@ export class Train extends BaseEntity {
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string;
|
||||
|
||||
/** Yard where the train currently sits (set at build, moved on schedule arrival). */
|
||||
@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;
|
||||
|
||||
// --- relationships ---
|
||||
@OneToMany(() => Wagon, (wagon) => wagon.train)
|
||||
wagons!: Wagon[]; // fixed typo: was 'wagens'
|
||||
@OneToMany(() => Wagon, (wagon) => wagon.train)
|
||||
wagons!: Wagon[];
|
||||
|
||||
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
|
||||
@OneToMany(() => TrainLocomotive, (link) => link.train)
|
||||
locomotives?: TrainLocomotive[];
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
|
||||
@ApiTags('train-builder')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-builder')
|
||||
@FleetView()
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
|
||||
build(@Body() dto: BuildTrainDto) {
|
||||
return this.trainBuilderService.buildTrain(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Paginated built trains with composition summary' })
|
||||
list(@Query() query: ListBuiltTrainsQueryDto) {
|
||||
return this.trainBuilderService.listBuilt(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
|
||||
composition(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.getComposition(id);
|
||||
}
|
||||
|
||||
@Put(':id/locomotives')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
|
||||
setLocomotives(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTrainLocomotivesDto,
|
||||
) {
|
||||
return this.trainBuilderService.setLocomotives(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
|
||||
return this.trainBuilderService.assignWagons(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
) {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
|
||||
return this.trainBuilderService.reorderWagons(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
||||
disband(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.disband(id);
|
||||
}
|
||||
}
|
||||
507
apps/edr-freight-api/src/modules/trains/train-builder.service.ts
Normal file
507
apps/edr-freight-api/src/modules/trains/train-builder.service.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { Freight, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
|
||||
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
|
||||
* ordered wagons, all in one yard) that scheduling can later reference as a
|
||||
* unit instead of hand-picking locomotives per departure.
|
||||
*
|
||||
* Resource rules:
|
||||
* - Locomotive double-use is prevented through the `train_locomotives` link
|
||||
* table (a locomotive rides at most one built train); its `status` column
|
||||
* keeps its operational meaning (ASSIGNED = out on a dispatched train).
|
||||
* - Wagons attached to a train are flipped to ASSIGNED (same semantic the
|
||||
* legacy assign-train flow uses), so no other train or schedule grabs them.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TrainBuilderService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async buildTrain(dto: BuildTrainDto) {
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||
}
|
||||
|
||||
const trainId = await this.dataSource.transaction(async (manager) => {
|
||||
const code = dto.code.trim();
|
||||
const existing = await manager.getRepository(Train).findOne({ where: { code } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Train code ${code} is already in use`);
|
||||
}
|
||||
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
|
||||
|
||||
const locomotives = await this.validateAndLockLocomotives(
|
||||
manager,
|
||||
locomotiveIds,
|
||||
yard,
|
||||
null,
|
||||
);
|
||||
|
||||
// Effective haul capacity is capped by the weakest locomotive in the set.
|
||||
const limits = minLocomotiveLimits(locomotives);
|
||||
const train = await manager.getRepository(Train).save(
|
||||
manager.getRepository(Train).create({
|
||||
code,
|
||||
currentYardId: yard.id,
|
||||
capacityTons: round(limits?.maxPullWeightTons ?? 0),
|
||||
status: Freight.TrainStatus.Available,
|
||||
trainName: dto.trainName?.trim() || undefined,
|
||||
notes: dto.notes?.trim() || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
||||
|
||||
if (dto.wagonIds?.length) {
|
||||
await this.attachWagons(manager, train, dto.wagonIds, 0);
|
||||
}
|
||||
return train.id;
|
||||
});
|
||||
|
||||
return this.getComposition(trainId);
|
||||
}
|
||||
|
||||
/** Paginated builder list with a composition summary per train. */
|
||||
async listBuilt(query: ListBuiltTrainsQueryDto) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
const search = query.search?.trim();
|
||||
const filters = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
||||
};
|
||||
const where = search
|
||||
? [
|
||||
{ ...filters, code: ILike(`%${search}%`) },
|
||||
{ ...filters, trainName: ILike(`%${search}%`) },
|
||||
]
|
||||
: filters;
|
||||
|
||||
const [trains, total] = await this.dataSource.getRepository(Train).findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
currentYard: true,
|
||||
locomotives: { locomotive: true },
|
||||
wagons: { wagonType: true },
|
||||
},
|
||||
order: { [query.sortBy ?? 'createdAt']: query.sortOrder ?? 'DESC' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
return {
|
||||
items: trains.map((train) => this.mapSummary(train)),
|
||||
meta: buildPaginationMeta(total, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
|
||||
async getComposition(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
currentYard: true,
|
||||
locomotives: { locomotive: { currentYard: true } },
|
||||
wagons: { wagonType: true, currentYard: true },
|
||||
},
|
||||
order: {
|
||||
locomotives: { sequenceNo: 'ASC' },
|
||||
wagons: { sequenceNumber: 'ASC' },
|
||||
},
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
|
||||
const schedules: { id: string; status: string; reference: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
ORDER BY ts.scheduled_departure_date ASC`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const locomotives = (train.locomotives ?? [])
|
||||
.filter((link) => link.locomotive)
|
||||
.map((link, index) => ({
|
||||
id: link.locomotive!.id,
|
||||
code: link.locomotive!.code,
|
||||
name: link.locomotive!.name ?? null,
|
||||
locomotiveType: link.locomotive!.locomotiveType,
|
||||
status: link.locomotive!.status,
|
||||
sequenceNo: link.sequenceNo,
|
||||
role: index === 0 ? 'LEAD' : 'ASSIST',
|
||||
currentYardId: link.locomotive!.currentYardId ?? null,
|
||||
currentYard: link.locomotive!.currentYard
|
||||
? {
|
||||
id: link.locomotive!.currentYard.id,
|
||||
code: link.locomotive!.currentYard.code,
|
||||
label: link.locomotive!.currentYard.label,
|
||||
}
|
||||
: null,
|
||||
maxPullWeightTons: round(link.locomotive!.maxPullWeightTons),
|
||||
maxTrainLengthMeters: round(link.locomotive!.maxTrainLengthMeters),
|
||||
}));
|
||||
|
||||
const wagons = (train.wagons ?? []).map((wagon) => ({
|
||||
id: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
sequenceNumber: wagon.sequenceNumber,
|
||||
status: wagon.status,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
capacityTons: round(wagon.wagonType.capacityTons),
|
||||
tareWeightTons: round(wagon.wagonType.tareWeightTons),
|
||||
lengthMeters: round(wagon.wagonType.lengthMeters),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
const limits = minLocomotiveLimits(
|
||||
(train.locomotives ?? [])
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||
);
|
||||
const totalTareTons = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const totalCapacityTons = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.capacityTons ?? 0), 0),
|
||||
);
|
||||
const totalLengthMeters = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
const maxGrossTons = round(totalTareTons + totalCapacityTons);
|
||||
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
|
||||
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
|
||||
|
||||
return {
|
||||
id: train.id,
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
notes: train.notes ?? null,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
||||
: null,
|
||||
locomotives,
|
||||
wagons,
|
||||
totals: {
|
||||
wagonCount: wagons.length,
|
||||
totalTareTons,
|
||||
totalCapacityTons,
|
||||
maxGrossTons,
|
||||
totalLengthMeters,
|
||||
maxPullWeightTons,
|
||||
maxTrainLengthMeters,
|
||||
// Fully loaded gross vs. what the weakest locomotive can haul.
|
||||
weightUtilizationPct: maxPullWeightTons
|
||||
? round((maxGrossTons / maxPullWeightTons) * 100)
|
||||
: null,
|
||||
lengthUtilizationPct: maxTrainLengthMeters
|
||||
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
|
||||
: null,
|
||||
},
|
||||
activeSchedules: schedules,
|
||||
// Composition is frozen while the train is out on a dispatched run.
|
||||
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
|
||||
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const yard = await manager
|
||||
.getRepository(Yard)
|
||||
.findOne({ where: { id: train.currentYardId ?? '' } });
|
||||
if (!yard) {
|
||||
throw new BadRequestException('Train has no yard; set the yard before changing locomotives');
|
||||
}
|
||||
const locomotives = await this.validateAndLockLocomotives(
|
||||
manager,
|
||||
locomotiveIds,
|
||||
yard,
|
||||
train.id,
|
||||
);
|
||||
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
||||
const limits = minLocomotiveLimits(locomotives);
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Append AVAILABLE wagons from the train's own yard to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const currentCount = await manager
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId: train.id } });
|
||||
await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (wagon.currentTrainScheduleId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagons = await manager
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { trainId: train.id } });
|
||||
const current = new Set(wagons.map((w) => w.id));
|
||||
const incoming = new Set(dto.wagonIds);
|
||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||
}
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Disband the train: release wagons and locomotives, then delete it. */
|
||||
async disband(id: string): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
const active: { count: string }[] = await manager.query(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
||||
[id],
|
||||
);
|
||||
if (Number(active[0]?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
'Train has active schedules; cancel them before disbanding the train',
|
||||
);
|
||||
}
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ trainId: train.id },
|
||||
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
|
||||
);
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
||||
await manager.getRepository(Train).remove(train);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
private mapSummary(train: Train) {
|
||||
const locomotives = [...(train.locomotives ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco));
|
||||
const wagons = train.wagons ?? [];
|
||||
const maxGrossTons = round(
|
||||
wagons.reduce(
|
||||
(sum, w) =>
|
||||
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
return {
|
||||
id: train.id,
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
||||
: null,
|
||||
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
|
||||
wagonCount: wagons.length,
|
||||
maxGrossTons,
|
||||
totalLengthMeters: round(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||
),
|
||||
maxPullWeightTons: round(train.capacityTons),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
||||
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
return train;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock and validate the locomotives for a build/replace: each must exist, be
|
||||
* serviceable, sit in the train's yard, and not ride another built train.
|
||||
*/
|
||||
private async validateAndLockLocomotives(
|
||||
manager: EntityManager,
|
||||
locomotiveIds: string[],
|
||||
yard: Yard,
|
||||
ownTrainId: string | null,
|
||||
): Promise<Locomotive[]> {
|
||||
const locomotives: Locomotive[] = [];
|
||||
for (const locomotiveId of locomotiveIds) {
|
||||
const locked = await manager.getRepository(Locomotive).findOne({
|
||||
where: { id: locomotiveId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!locked) throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||
if (locked.status === 'OUT_OF_SERVICE' || locked.status === 'MAINTENANCE') {
|
||||
throw new ConflictException(`Locomotive ${locked.code} is ${locked.status.toLowerCase().replace('_', ' ')}`);
|
||||
}
|
||||
if (locked.currentYardId !== yard.id) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locked.code} is not in yard ${yard.label ?? yard.code}; a train can only be built from locomotives in its own yard`,
|
||||
);
|
||||
}
|
||||
locomotives.push(locked);
|
||||
}
|
||||
|
||||
const taken = await manager.getRepository(TrainLocomotive).find({
|
||||
where: { locomotiveId: In(locomotiveIds) },
|
||||
relations: { train: true },
|
||||
});
|
||||
const conflict = taken.find((link) => link.trainId !== ownTrainId);
|
||||
if (conflict) {
|
||||
const loco = locomotives.find((l) => l.id === conflict.locomotiveId);
|
||||
throw new ConflictException(
|
||||
`Locomotive ${loco?.code ?? conflict.locomotiveId} is already coupled to train ${conflict.train?.code ?? conflict.trainId}`,
|
||||
);
|
||||
}
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
private async replaceLocomotiveLinks(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
locomotiveIds: string[],
|
||||
): Promise<void> {
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId });
|
||||
await manager.getRepository(TrainLocomotive).save(
|
||||
locomotiveIds.map((locomotiveId, index) =>
|
||||
manager.getRepository(TrainLocomotive).create({ trainId, locomotiveId, sequenceNo: index }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async attachWagons(
|
||||
manager: EntityManager,
|
||||
train: Train,
|
||||
wagonIds: string[],
|
||||
startCount: number,
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(wagonIds)];
|
||||
let sequence = startCount;
|
||||
for (const wagonId of uniqueIds) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({
|
||||
where: { id: wagonId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
||||
if (wagon.trainId === train.id) continue;
|
||||
if (wagon.trainId) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
|
||||
}
|
||||
if (wagon.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
|
||||
}
|
||||
if (wagon.currentYardId !== train.currentYardId) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||
);
|
||||
}
|
||||
sequence += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: train.id,
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact wagon sequence numbers back to 1..n after a removal. */
|
||||
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
|
||||
const wagons = await manager.getRepository(Wagon).find({
|
||||
where: { trainId },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
for (let i = 0; i < wagons.length; i++) {
|
||||
if (wagons[i].sequenceNumber !== i + 1) {
|
||||
await manager.getRepository(Wagon).update(wagons[i].id, { sequenceNumber: i + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
// apps/edr-freight-api/src/modules/trains/trains.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import { TrainBuilderController } from './train-builder.controller';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
import { TrainsController } from './trains.controller';
|
||||
import { TrainsService } from './trains.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Train])],
|
||||
controllers: [TrainsController],
|
||||
providers: [TrainsService],
|
||||
exports: [TrainsService], // if other modules need it
|
||||
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])],
|
||||
controllers: [TrainsController, TrainBuilderController],
|
||||
providers: [TrainsService, TrainBuilderService],
|
||||
exports: [TrainsService, TrainBuilderService],
|
||||
})
|
||||
export class TrainsModule {}
|
||||
export class TrainsModule {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
||||
* those at fulfilment.
|
||||
*/
|
||||
export class CreateTransferRequestDto {
|
||||
@IsUUID()
|
||||
fromYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
toYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
wagonTypeId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC fulfilment: the specific wagons hand-picked to satisfy a transfer request.
|
||||
* The service validates they all sit in the request's source yard, match its
|
||||
* wagon type, and number exactly the requested quantity.
|
||||
*/
|
||||
export class FulfillTransferRequestDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
/**
|
||||
* A two-person wagon relocation request. A requester asks for `quantity` wagons
|
||||
* of `wagonTypeId` to move from `fromYardId` to `toYardId` — specifying a count
|
||||
* only, never the physical wagons. OCC staff later open the PENDING request,
|
||||
* hand-pick the actual wagons in the source yard, and execute the transfer
|
||||
* (which writes the `wagon_movements` ledger and marks this FULFILLED).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_transfer_requests' })
|
||||
@Index(['status', 'fromYardId'])
|
||||
export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'from_yard_id', type: 'uuid' })
|
||||
fromYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'from_yard_id' })
|
||||
fromYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||
toYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'to_yard_id' })
|
||||
toYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
||||
wagonTypeId!: string;
|
||||
|
||||
@ManyToOne(() => WagonType)
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType | null;
|
||||
|
||||
/** How many wagons of `wagonTypeId` to move out of `fromYardId`. */
|
||||
@Column({ name: 'quantity', type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: WagonTransferRequestStatus.Pending,
|
||||
})
|
||||
status!: WagonTransferRequestStatus;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
||||
fulfilledByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
||||
fulfilledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
|
||||
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
|
||||
* the move. Separate top-level path so it never collides with `wagons/:id`.
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView()
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@WagonTransferRequest()
|
||||
@ApiOperation({ summary: 'File a count-only wagon-transfer request' })
|
||||
create(
|
||||
@Body() dto: CreateTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.createRequest(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
|
||||
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
|
||||
list(@Query('status') status?: WagonTransferRequestStatus) {
|
||||
return this.service.listRequests(status);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one transfer request' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post(':id/fulfill')
|
||||
@WagonTransferFulfill()
|
||||
@ApiOperation({ summary: 'OCC: pick wagons and execute the transfer' })
|
||||
fulfill(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: FulfillTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.fulfillRequest(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
toYard: true,
|
||||
wagonType: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer workflow. A requester records a count-only request
|
||||
* (see `createRequest`); OCC staff later open the PENDING queue, hand-pick the
|
||||
* physical wagons, and `fulfillRequest` validates + executes the move. Replaces
|
||||
* the single-step instant bulk transfer.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonTransferRequestsService {
|
||||
constructor(
|
||||
@InjectRepository(WagonTransferRequest)
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
/** Record a PENDING request. Count-only — no wagons are picked here. */
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
if (dto.fromYardId === dto.toYardId) {
|
||||
throw new BadRequestException(
|
||||
'Source and destination yard must be different',
|
||||
);
|
||||
}
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
wagonTypeId: dto.wagonTypeId,
|
||||
quantity: dto.quantity,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: userId ?? null,
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
const saved = await this.requestRepo.save(request);
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||
async listRequests(
|
||||
status?: WagonTransferRequestStatus,
|
||||
): Promise<WagonTransferRequest[]> {
|
||||
return this.requestRepo.find({
|
||||
where: status ? { status } : {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.requestRepo.findOne({
|
||||
where: { id },
|
||||
relations: REQUEST_RELATIONS,
|
||||
});
|
||||
if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
|
||||
* in the request's source yard, match its wagon type, and the count must equal
|
||||
* the requested quantity — then the transfer runs and the request is marked
|
||||
* FULFILLED.
|
||||
*/
|
||||
async fulfillRequest(
|
||||
id: string,
|
||||
dto: FulfillTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagonIds = [...new Set(dto.wagonIds)];
|
||||
if (wagonIds.length !== request.quantity) {
|
||||
throw new BadRequestException(
|
||||
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await this.wagonRepo.find({ where: { id: In(wagonIds) } });
|
||||
if (wagons.length !== wagonIds.length) {
|
||||
throw new NotFoundException('One or more selected wagons not found');
|
||||
}
|
||||
const offSource = wagons.filter((w) => w.currentYardId !== request.fromYardId);
|
||||
if (offSource.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are not in the source yard: ${offSource
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const wrongType = wagons.filter((w) => w.wagonTypeId !== request.wagonTypeId);
|
||||
if (wrongType.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are the wrong type: ${wrongType
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds, toYardId: request.toYardId },
|
||||
userId,
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Withdraw a still-PENDING request. */
|
||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
|
||||
);
|
||||
}
|
||||
request.status = WagonTransferRequestStatus.Cancelled;
|
||||
await this.requestRepo.save(request);
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
|
||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])],
|
||||
controllers: [WagonsController, TrainWagonsReorderController],
|
||||
providers: [WagonsService],
|
||||
exports: [WagonsService],
|
||||
imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
TrainWagonsReorderController,
|
||||
WagonTransferRequestsController,
|
||||
],
|
||||
providers: [WagonsService, WagonTransferRequestsService],
|
||||
exports: [WagonsService, WagonTransferRequestsService],
|
||||
})
|
||||
export class WagonsModule {}
|
||||
|
||||
Reference in New Issue
Block a user