feat(train-sets): implement multi-locomotive support for train sets

- Added TrainSetLocomotive entity to link multiple locomotives to a train set.
- Updated TrainSet entity to include a OneToMany relationship with TrainSetLocomotive.
- Modified the train scheduling logic to require at least two locomotives for a train set.
- Enhanced the UI components to support selecting multiple locomotives.
- Introduced new permissions for viewing customs clearance.
- Updated migrations to create the train_set_locomotives table and backfill existing data.
- Implemented utility functions for managing train numbers based on cargo type and direction.
- Added tests for train number utilities to ensure correct functionality.
This commit is contained in:
Marshal
2026-06-24 23:54:45 +00:00
parent 15ab9f906e
commit e0c3044933
28 changed files with 817 additions and 118 deletions

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Multi-locomotive train sets: a train set is now pulled by 2+ locomotives.
*
* Adds the `freight.train_set_locomotives` link table (train set ⇄ locomotive,
* with an order index) and backfills one row per existing train set from its
* current `locomotive_id`, so existing read paths keep resolving locomotives.
* The `train_sets.locomotive_id` column is retained as the "primary" locomotive.
*
* NOTE: the shared dev DB has no applied migration history, so this is also
* hand-applied there. IF NOT EXISTS keeps that idempotent.
*/
export class AddTrainSetLocomotives1820000000011 implements MigrationInterface {
name = 'AddTrainSetLocomotives1820000000011';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_set_locomotives (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
train_set_id uuid NOT NULL,
locomotive_id uuid NOT NULL,
sequence_no int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "PK_train_set_locomotives" PRIMARY KEY (id),
CONSTRAINT "FK_train_set_locomotives_train_set" FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets (id) ON DELETE CASCADE,
CONSTRAINT "FK_train_set_locomotives_locomotive" FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives (id)
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_set_locomotives_set_loco"
ON freight.train_set_locomotives (train_set_id, locomotive_id);
`);
// Backfill: one link row per existing train set, from its current primary loco.
await queryRunner.query(`
INSERT INTO freight.train_set_locomotives (train_set_id, locomotive_id, sequence_no)
SELECT ts.id, ts.locomotive_id, 0
FROM freight.train_sets ts
WHERE ts.locomotive_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.train_set_locomotives tsl
WHERE tsl.train_set_id = ts.id AND tsl.locomotive_id = ts.locomotive_id
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_train_set_locomotives_set_loco";`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_locomotives;`);
}
}

View File

@@ -134,6 +134,12 @@ export class BookingsController {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general
// booking-request list.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
@@ -236,8 +242,12 @@ export class BookingsController {
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
// Staff see any booking; Global Logistics (clearance:view) may inspect any
// booking for the clearance gate; customers only their own company's.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,

View File

@@ -742,6 +742,45 @@ export class BookingsService {
'AWAITING_PAYMENT',
];
/**
* Booking statuses that belong to the customs document-clearance queue. The
* Global Logistics role is scoped to ONLY these — it never sees the general
* booking-request list.
*/
private static readonly CLEARANCE_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
];
/**
* List bookings in the customs document-clearance queue. Used by Global
* Logistics (clearance:view) which has no general bookings:view — so the
* status set is force-scoped to clearance statuses and can't be widened to
* arbitrary bookings by a caller-supplied status filter.
*/
async findClearanceQueue(
filter: FilterBookingDto,
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 100;
// Honour a caller status filter only if it's within the clearance set;
// otherwise fall back to the full clearance status list.
const requested = filter.status;
const statuses =
requested && BookingsService.CLEARANCE_STATUSES.includes(requested)
? [requested]
: BookingsService.CLEARANCE_STATUSES;
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
statuses,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* List the current customer's bookings that are ready for payment:
* payable status AND not yet PAID. Company scope is derived from the

View File

@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
route: true,
trainSet: {
locomotive: true,
locomotives: { locomotive: true },
wagons: {
wagonType: true,
physicalWagon: true,

View File

@@ -1,6 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
} from 'class-validator';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto {
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back)',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()

View File

@@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive(
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
* locomotive caps the train, so take the minimum pull weight and minimum length
* across all assigned locomotives. Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
),
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
),
};
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
freightType: string | null | undefined,

View File

@@ -0,0 +1,52 @@
import {
BULK_IMPORT_NUMBERS,
CONTAINER_EXPORT_NUMBERS,
CONTAINER_IMPORT_NUMBERS,
pickLowestFreeNumber,
pickTrainNumberPool,
} from './train-number.util';
describe('train-number.util', () => {
describe('pickTrainNumberPool', () => {
it('picks container export (odd) when container wagons dominate and direction is EXPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'EXPORT');
expect(pool.cargo).toBe('CONTAINER');
expect(pool.direction).toBe('EXPORT');
expect(pool.numbers).toEqual(CONTAINER_EXPORT_NUMBERS);
});
it('picks container import (even) when container wagons dominate and direction is IMPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'IMPORT');
expect(pool.numbers).toEqual(CONTAINER_IMPORT_NUMBERS);
});
it('picks bulk when bulk wagons dominate', () => {
const pool = pickTrainNumberPool(1, 9, 'IMPORT');
expect(pool.cargo).toBe('BULK');
expect(pool.numbers).toEqual(BULK_IMPORT_NUMBERS);
});
it('treats a tie as container', () => {
expect(pickTrainNumberPool(3, 3, 'EXPORT').cargo).toBe('CONTAINER');
});
it('defaults DOMESTIC to the export/odd pool', () => {
expect(pickTrainNumberPool(5, 0, 'DOMESTIC').direction).toBe('EXPORT');
expect(pickTrainNumberPool(5, 0, null).direction).toBe('EXPORT');
});
});
describe('pickLowestFreeNumber', () => {
it('returns the lowest unused number', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, ['8001'])).toBe('8101');
});
it('returns the first number when none are used', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, [])).toBe('8001');
});
it('returns null when the pool is exhausted', () => {
expect(pickLowestFreeNumber(BULK_IMPORT_NUMBERS, [...BULK_IMPORT_NUMBERS])).toBeNull();
});
});
});

View File

@@ -0,0 +1,68 @@
/**
* Fixed train-number pools assigned to a train on dispatch.
*
* The prefix encodes cargo type (8 = container, 1 = bulk) and the parity encodes
* trade direction (odd = export, even = import). Numbers are finite and recycle:
* a number is "in use" only while its train is DISPATCHED and not yet ARRIVED.
*/
export const CONTAINER_EXPORT_NUMBERS = [
'8001', '8101', '8201', '8301', '8401', '8501', '8601', '8701', '8801', '8901',
] as const;
export const CONTAINER_IMPORT_NUMBERS = [
'8002', '8102', '8202', '8302', '8402', '8502', '8602', '8702', '8802', '8902',
] as const;
export const BULK_EXPORT_NUMBERS = ['1101', '1103', '1105', '1107'] as const;
export const BULK_IMPORT_NUMBERS = ['1002', '1004', '1006', '1008'] as const;
export type CargoKind = 'CONTAINER' | 'BULK';
export type PoolDirection = 'IMPORT' | 'EXPORT';
export interface TrainNumberPool {
cargo: CargoKind;
/** EXPORT = odd numbers, IMPORT = even numbers. */
direction: PoolDirection;
numbers: readonly string[];
}
/**
* Resolve which fixed pool a train draws from.
*
* - Cargo: container vs bulk by dominant wagon count; ties resolve to container.
* - Direction: EXPORT → odd pool, IMPORT → even pool. DOMESTIC (neither end is
* Djibouti) has no dedicated pool, so it defaults to the export/odd pool.
*/
export function pickTrainNumberPool(
containerWagons: number,
bulkWagons: number,
direction: 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null | undefined,
): TrainNumberPool {
const cargo: CargoKind = bulkWagons > containerWagons ? 'BULK' : 'CONTAINER';
const poolDirection: PoolDirection = direction === 'IMPORT' ? 'IMPORT' : 'EXPORT';
const numbers =
cargo === 'CONTAINER'
? poolDirection === 'IMPORT'
? CONTAINER_IMPORT_NUMBERS
: CONTAINER_EXPORT_NUMBERS
: poolDirection === 'IMPORT'
? BULK_IMPORT_NUMBERS
: BULK_EXPORT_NUMBERS;
return { cargo, direction: poolDirection, numbers };
}
/** Lowest pool number not currently in use, or null when the pool is exhausted. */
export function pickLowestFreeNumber(
pool: readonly string[],
usedNumbers: Iterable<string>,
): string | null {
const used = new Set(usedNumbers);
for (const number of pool) {
if (!used.has(number)) return number;
}
return null;
}

View File

@@ -7,6 +7,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
@@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
WagonType,
TrainSet,
TrainSetWagon,
TrainSetLocomotive,
Route,
Wagon,
Container,

View File

@@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => {
isActive: true,
};
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
const lockedLocomotiveRepo = {
findOne: jest.fn().mockResolvedValue(locomotive),
findOne: jest
.fn()
.mockResolvedValueOnce(locomotive)
.mockResolvedValueOnce(locomotive2),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
@@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const trainSetLocomotiveRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
@@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => {
return trainScheduleRepo;
case 'TrainSet':
return trainSetRepo;
case 'TrainSetLocomotive':
return trainSetLocomotiveRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
@@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => {
const result = await service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
{ status: 'ASSIGNED' },
);
expect(result.id).toBe('schedule-1');
});
@@ -508,7 +521,6 @@ describe('TrainSchedulingService', () => {
})),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
@@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => {
service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
}),
).rejects.toBeInstanceOf(ConflictException);
});

View File

@@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
@@ -79,8 +80,10 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import {
@@ -288,31 +291,42 @@ export class TrainSchedulingService {
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
where: { id: locomotive.id },
lock: { mode: 'pessimistic_write' },
});
if (!lockedLocomotive) {
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
}
if (lockedLocomotive.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
const lockedLocomotives: 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 !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${locked.code} is not available`);
}
if (locked.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
lockedLocomotives.push(locked);
}
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
if (lockedLocomotive.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
@@ -322,11 +336,14 @@ export class TrainSchedulingService {
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
await this.resolveTrainLimitConfig(dto, limitLoco)
).maxWagonsPerTrain,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
await manager.getRepository(Locomotive).update(
{ id: In(lockedLocomotives.map((l) => l.id)) },
{ status: 'ASSIGNED' },
);
return saved.id;
});
@@ -375,8 +392,9 @@ export class TrainSchedulingService {
maxWagonsPerTrain: dto.maxWagonsPerTrain,
};
const locomotive = schedule.trainSet.locomotive;
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -408,17 +426,17 @@ export class TrainSchedulingService {
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
if (!locomotive) {
throw new BadRequestException('Schedule train set has no locomotive');
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
if (limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
}
@@ -681,10 +699,12 @@ export class TrainSchedulingService {
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{ actualDepartureAt: now },
{ actualDepartureAt: now, trainNumber },
manager,
);
if (schedule.trainSetId) {
@@ -718,6 +738,60 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* Assign a fixed train number on dispatch. The number is drawn from the pool
* for the train's dominant cargo type (container vs bulk) and trade direction
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
* concurrent dispatches can't grab the same number. Throws when the pool is
* exhausted. Idempotent: returns the existing number if already assigned.
*/
private async assignTrainNumber(
manager: EntityManager,
schedule: TrainSchedule,
): Promise<string> {
if (schedule.trainNumber) return schedule.trainNumber;
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
let bulkWagons = 0;
for (const wagon of schedule.trainSet?.wagons ?? []) {
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
if (isBulk) bulkWagons += 1;
else containerWagons += 1;
}
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
const usedNumbers = activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n));
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
throw new ConflictException(
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
);
}
return number;
}
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
await this.dataSource
@@ -931,16 +1005,12 @@ export class TrainSchedulingService {
});
}
if (schedule.trainSet?.locomotiveId) {
const loco = await manager
.getRepository(Locomotive)
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
if (loco) {
await manager.getRepository(Locomotive).update(loco.id, {
status: 'AVAILABLE',
currentYardId: schedule.destinationStationId,
});
}
const arrivingLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (arrivingLocoIds.length) {
await manager.getRepository(Locomotive).update(
{ id: In(arrivingLocoIds) },
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
);
}
for (const slot of schedule.trainSet?.wagons ?? []) {
@@ -982,7 +1052,7 @@ export class TrainSchedulingService {
async getContainerTrainSchedules() {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: true,
originStation: true,
destinationStation: true,
@@ -1013,10 +1083,11 @@ export class TrainSchedulingService {
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
}
if (schedule.trainSet?.locomotiveId) {
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
status: 'AVAILABLE',
});
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (cancelledLocoIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
@@ -1251,24 +1322,29 @@ export class TrainSchedulingService {
}
}
let assignedLocomotive: Locomotive | null = null;
let assignedLocomotives: Locomotive[] = [];
if (targetScheduleId) {
const targetSchedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
}
if (assignedLocomotive) {
if (assignedLocomotive.currentYardId !== originYardId) {
if (assignedLocomotives.length) {
// Every locomotive of the set must sit at the origin yard, and the weakest
// one must still be able to pull the train (min limits across the set).
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
if (offYard) {
violations.push(
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
`Locomotive ${offYard.code} is not at the schedule origin yard`,
);
} else if (
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
setLimits &&
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
'Assigned locomotive cannot support the total train weight and length',
'Assigned locomotives cannot support the total train weight and length',
);
}
} else {
@@ -1818,6 +1894,22 @@ export class TrainSchedulingService {
}
}
/**
* All locomotives attached to a loaded train set. Prefers the `locomotives`
* link rows; falls back to the legacy single `locomotive` for train sets
* created before multi-loco support.
*/
private locomotivesOfTrainSet(
trainSet: TrainSet | null | undefined,
): Locomotive[] {
if (!trainSet) return [];
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
if (linked.length) return linked;
return trainSet.locomotive ? [trainSet.locomotive] : [];
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
@@ -1841,15 +1933,28 @@ export class TrainSchedulingService {
return locomotive;
}
private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) {
private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) {
const [primary] = locomotives;
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
locomotiveId: primary.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
const saved = await manager.getRepository(TrainSet).save(trainSet);
const links = locomotives.map((loco, index) =>
manager.getRepository(TrainSetLocomotive).create({
trainSetId: saved.id,
locomotiveId: loco.id,
sequenceNo: index,
}),
);
await manager.getRepository(TrainSetLocomotive).save(links);
return saved;
}
private async getActiveRoute(routeId: string) {
@@ -1915,6 +2020,12 @@ export class TrainSchedulingService {
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
currentYardId: loco.currentYardId ?? null,
})),
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
@@ -1952,7 +2063,7 @@ export class TrainSchedulingService {
bookingWindowStatus: 'OPEN',
},
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: { milestones: true },
originStation: true,
destinationStation: true,
@@ -2099,6 +2210,15 @@ export class TrainSchedulingService {
),
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
status: loco.status,
currentYardId: loco.currentYardId ?? null,
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSet } from './train-set.entity';
/**
* Link row joining a train set to one of its locomotives. A train set must be
* pulled by at least two locomotives (front + back); `sequenceNo` is a plain
* order index — no front/rear semantics are modelled yet.
*/
@Entity({ schema: 'freight', name: 'train_set_locomotives' })
@Index(['trainSetId', 'locomotiveId'], { unique: true })
export class TrainSetLocomotive extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid' })
trainSetId!: string;
@ManyToOne(() => TrainSet, (trainSet) => trainSet.locomotives, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@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;
}

View File

@@ -3,6 +3,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetLocomotive } from './train-set-locomotive.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
export const TRAIN_SET_STATUSES = [
@@ -19,6 +20,7 @@ export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
@Index(['locomotiveId'])
@Index(['status'])
export class TrainSet extends BaseEntity {
/** Primary locomotive (first of the set). Kept for back-compat with single-loco read paths. */
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@@ -26,6 +28,10 @@ export class TrainSet extends BaseEntity {
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
/** All locomotives pulling this train set (minimum 2). */
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;

View File

@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSet } from './entities/train-set.entity';
import { TrainSetLocomotive } from './entities/train-set-locomotive.entity';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
import { TrainSetsRepository } from './train-sets.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon, TrainSetLocomotive])],
providers: [TrainSetsRepository, TrainSetWagonsRepository],
exports: [TrainSetsRepository, TrainSetWagonsRepository],
})

View File

@@ -51,6 +51,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
perm('a1000001-0001-4000-8000-000000000023', 'edr_freight_app:bookings:clearance_view', 'View customs-clearance queue'),
perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'),
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
@@ -97,6 +98,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO
export const FREIGHT_PERMS = {
bookings: {
view: 'edr_freight_app:bookings:view',
clearanceView: 'edr_freight_app:bookings:clearance_view',
staffAccept: 'edr_freight_app:bookings:staff_accept',
requestChanges: 'edr_freight_app:bookings:request_changes',
reject: 'edr_freight_app:bookings:reject',
@@ -171,10 +173,13 @@ export const ROLE_PERMISSION_PRESETS = {
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
// Global Logistics: reviews post-counter-sign clearance documents, uploads
// customs output documents, and finalizes the clearance gate.
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
// the general booking-request list (no bookings:view) — instead a dedicated
// clearance:view permission lists the clearance bookings. Reviews customer
// clearance documents, uploads customs output documents, and finalizes the
// clearance gate.
globalLogistics: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.finalizeClearance,

View File

@@ -93,6 +93,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Customers",
@@ -367,7 +368,17 @@ const App = () => {
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="booking-requests"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.view}
redirectTo="/dashboard/clearance"
>
<BookingRequestsPage />
</RequirePermission>
}
/>
<Route
path="payments"
element={
@@ -378,11 +389,29 @@ const App = () => {
/>
<Route path="customers" element={<CustomersPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} />
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/new"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<NewBookingPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingRequestDetailPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingContractPage />
</RequirePermission>
}
/>
<Route
path="clearance"

View File

@@ -8,6 +8,7 @@ import {
Checkbox,
Group,
Modal,
MultiSelect,
Paper,
Radio,
RingProgress,
@@ -107,7 +108,7 @@ export function AllocateBookingWizard({
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
const [routeId, setRouteId] = useState("");
const scheduleDate = booking.scheduledDate;
const [locomotiveId, setLocomotiveId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
@@ -152,7 +153,7 @@ export function AllocateBookingWizard({
useEffect(() => {
if (scheduleMode === "new") {
setLocomotiveId("");
setLocomotiveIds([]);
}
}, [routeId, scheduleMode]);
@@ -264,11 +265,11 @@ export function AllocateBookingWizard({
const ensureSchedule = async (): Promise<string> => {
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
if (!routeId || !scheduleDate || !locomotiveId) {
throw new Error("Select route, date, and locomotive");
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
throw new Error("Select route, date, and at least two locomotives");
}
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
payload: { routeId, scheduleDate, locomotiveIds },
});
setSelectedScheduleId(created.id);
return created.id;
@@ -526,17 +527,25 @@ export function AllocateBookingWizard({
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
<MultiSelect
label="Locomotives"
description="At least two (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}

View File

@@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
clearanceView: "edr_freight_app:bookings:clearance_view",
staffAccept: "edr_freight_app:bookings:staff_accept",
requestChanges: "edr_freight_app:bookings:request_changes",
reject: "edr_freight_app:bookings:reject",
@@ -82,6 +83,11 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
/** Can see/manage the customs document-clearance queue (Global Logistics). */
export function canViewClearance(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
}
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
}

View File

@@ -291,6 +291,14 @@ export default function TrainScheduleV2DetailPage() {
);
}
// All locomotives pulling the train (≥2), falling back to the legacy single loco.
const locomotives =
schedule.trainSet?.locomotives && schedule.trainSet.locomotives.length > 0
? schedule.trainSet.locomotives
: schedule.trainSet?.locomotive
? [schedule.trainSet.locomotive]
: [];
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
@@ -811,13 +819,13 @@ export default function TrainScheduleV2DetailPage() {
<KpiStrip
items={[
{
label: "Locomotive",
value: schedule.trainSet?.locomotive?.code ?? "—",
hint: schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set",
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
value: locomotives.length
? locomotives.map((l) => l.code).join(" + ")
: "—",
hint: locomotives.length
? `${locomotives.length} locomotive${locomotives.length > 1 ? "s" : ""}`
: "No locomotives assigned",
icon: Train,
},
{

View File

@@ -7,6 +7,7 @@ import {
Group,
Menu,
Modal,
MultiSelect,
Select,
SimpleGrid,
Stack,
@@ -82,7 +83,7 @@ export default function TrainScheduleV2ListPage() {
const [createOpen, setCreateOpen] = useState(false);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveId, setLocomotiveId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
@@ -113,7 +114,7 @@ export default function TrainScheduleV2ListPage() {
}, [selectedRoute]);
useEffect(() => {
setLocomotiveId("");
setLocomotiveIds([]);
}, [routeId]);
const allSchedules = schedulesQuery.data ?? [];
@@ -147,6 +148,7 @@ export default function TrainScheduleV2ListPage() {
s.origin,
s.destination,
s.locomotive?.code,
...(s.locomotives ?? []).map((l) => l.code),
s.freightType,
s.status,
]
@@ -229,21 +231,32 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "loco",
header: "Locomotive",
header: "Locomotives",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive?.code ? (
cell: ({ row }) => {
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
: row.original.locomotive
? [row.original.locomotive]
: [];
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{row.original.locomotive.code}
{locos[0].code}
{locos.length > 1 ? ` +${locos.length - 1}` : ""}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
),
);
},
},
{
id: "metrics",
@@ -331,13 +344,16 @@ export default function TrainScheduleV2ListPage() {
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !locomotiveId) {
toast({ title: "Select route, date, and locomotive", variant: "destructive" });
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
toast({
title: "Select route, date, and at least two locomotives",
variant: "destructive",
});
return;
}
try {
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
payload: { routeId, scheduleDate, locomotiveIds },
});
toast({ title: "Train schedule created" });
setCreateOpen(false);
@@ -532,17 +548,25 @@ export default function TrainScheduleV2ListPage() {
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}

View File

@@ -154,6 +154,13 @@ export interface TrainScheduleListItem {
currentYardId?: string | null;
}
| null;
/** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */
locomotives?: Array<{
id: string;
code: string;
name?: string | null;
currentYardId?: string | null;
}>;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
@@ -354,6 +361,16 @@ export interface TrainScheduleDetail {
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
/** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */
locomotives?: Array<{
id: string;
code: string;
name?: string | null;
status: string;
currentYardId?: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
}>;
wagons: Array<{
id: string;
sequenceNo: number;
@@ -462,7 +479,8 @@ export interface ReschedulePlan {
export interface CreateTrainSchedulePayload {
routeId: string;
scheduleDate: string;
locomotiveId: string;
/** Locomotives pulling the train (minimum 2 — front and back). */
locomotiveIds: string[];
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;

View File

@@ -478,7 +478,7 @@ export function AppLayout({
<Menu.Item
leftSection={<Plus size={15} />}
color="edr-green"
onClick={() => navigate("/bookings/new")}
onClick={() => navigate("/bookings/new", { state: { fresh: true } })}
>
New Booking
</Menu.Item>

View File

@@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({
</Group>
</Box>
<Link to="/bookings/new">
<Link to="/bookings/new" state={{ fresh: true }}>
<Group
gap={14}
align="center"

View File

@@ -93,7 +93,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
menuActions={{
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
onRebook: () => navigate("/bookings/new"),
onRebook: () => navigate("/bookings/new", { state: { fresh: true } }),
onSupport: () => navigate("/support"),
}}
/>
@@ -110,14 +110,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={() => navigate("/bookings/new")}
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
/>
) : isExpired ? (
<CancelledBanner
pillLabel="Expired"
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner

View File

@@ -654,6 +654,7 @@ export default function MyBookings() {
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
@@ -826,6 +827,7 @@ export default function MyBookings() {
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
size="sm"
color="edr-green"
radius="md"

View File

@@ -29,7 +29,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import {
BookingFormInputValues,
@@ -45,6 +45,10 @@ import {
type OperationType,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
import {
clearBookingDraft,
useBookingDraft,
} from "./new-booking-form/useBookingDraft";
import {
Step0OperationType,
Step1ContractType,
@@ -187,6 +191,8 @@ export default function NewBookingPage() {
setPriceChangeResult(result);
return;
}
// Booking submitted — the saved wizard draft is no longer needed.
clearBookingDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
// A partial-wagon booking is parked until a partner is found — explain the
@@ -205,6 +211,8 @@ export default function NewBookingPage() {
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: (result) => {
// Booking submitted — the saved wizard draft is no longer needed.
clearBookingDraft();
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -224,6 +232,8 @@ export default function NewBookingPage() {
return api.bookings.reject.call({ id: priceBookingId });
},
onSuccess: () => {
// The customer rejected this booking and will start over — drop the draft.
clearBookingDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate("/bookings");
@@ -247,6 +257,18 @@ export default function NewBookingPage() {
mode: "onChange",
});
// Persist the in-progress wizard to localStorage so a refresh doesn't lose it.
// The explicit "New Booking" entry points navigate with state.fresh = true to
// force a clean start; a plain refresh (no state) resumes the saved draft.
const location = useLocation();
const startFresh = (location.state as { fresh?: boolean } | null)?.fresh === true;
useBookingDraft({
form,
step,
setStep,
fresh: startFresh,
});
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import type { UseFormReturn } from "react-hook-form";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
const STORAGE_KEY = "edr.freight.bookingDraft.v1";
// Debounce writes so we don't hit localStorage on every keystroke.
const WRITE_DELAY_MS = 400;
interface BookingDraftSnapshot {
step: number;
values: Partial<BookingFormInputValues>;
savedAt: number;
}
/**
* Uploaded files can't be serialized to localStorage, so the documents map is
* stripped before persisting. The customer re-attaches files when they resume —
* everything else (operation, route, cargo, etc.) survives a refresh.
*/
function stripUnserializable(
values: BookingFormInputValues,
): Partial<BookingFormInputValues> {
const { documents: _documents, ...rest } = values;
return rest;
}
function readDraft(): BookingDraftSnapshot | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as BookingDraftSnapshot;
if (!parsed || typeof parsed !== "object" || !parsed.values) return null;
return parsed;
} catch {
// Corrupt or unavailable storage — treat as no draft.
return null;
}
}
export function clearBookingDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors (private mode, quota, etc.).
}
}
/**
* Persists the in-progress booking wizard to localStorage so a refresh (or
* accidental navigation) doesn't lose the customer's work, and restores it on
* the next visit.
*
* `fresh` is set by the explicit "New Booking" entry points (they navigate with
* `state: { fresh: true }`). A plain refresh has no such state, so:
* - fresh === true → discard any saved draft and start clean.
* - fresh !== true → restore the saved draft and resume where they left off.
*
* Returns `clearDraft` so the page can wipe the draft once the booking is
* actually submitted.
*/
export function useBookingDraft({
form,
step,
setStep,
fresh,
}: {
form: BookingForm;
step: number;
setStep: (step: number) => void;
fresh: boolean;
}): { clearDraft: () => void } {
// Restore (or clear) exactly once on mount.
const restoredRef = useRef(false);
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
if (fresh) {
clearBookingDraft();
return;
}
const draft = readDraft();
if (!draft) return;
// Merge over current defaults so any new schema fields keep their defaults.
form.reset(
{ ...form.getValues(), ...draft.values },
{ keepDefaultValues: true },
);
if (typeof draft.step === "number") setStep(draft.step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist on every form change (debounced) and whenever the step changes.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stepRef = useRef(step);
stepRef.current = step;
const write = () => {
try {
const snapshot: BookingDraftSnapshot = {
step: stepRef.current,
values: stripUnserializable(form.getValues()),
savedAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// Ignore storage errors (private mode, quota, etc.).
}
};
useEffect(() => {
// Don't persist until the initial restore/clear has run.
if (!restoredRef.current) return;
const sub = form.watch(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(write, WRITE_DELAY_MS);
});
return () => {
sub.unsubscribe();
if (timerRef.current) clearTimeout(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
// Step changes are immediate (no debounce) so a refresh lands on the right step.
useEffect(() => {
if (!restoredRef.current) return;
write();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return { clearDraft: clearBookingDraft };
}

View File

@@ -209,7 +209,7 @@ export default function ContractsList() {
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/bookings/new")}
onClick={() => navigate("/bookings/new", { state: { fresh: true } })}
>
New Contract
</Button>