diff --git a/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts new file mode 100644 index 000000000..b014e71a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts @@ -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 { + 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 { + 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;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 8197125f1..331706c5a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e05a3b35a..486e7eae3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { + 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 diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 8ec002d49..58e71143c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository { route: true, trainSet: { locomotive: true, + locomotives: { locomotive: true }, wagons: { wagonType: true, physicalWagon: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 5c2486fa3..60aab2862 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 593bb7bee..5ec385924 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -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>, +): 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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts new file mode 100644 index 000000000..ce3d6166b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts new file mode 100644 index 000000000..f4af19cc1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts @@ -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 | null { + const used = new Set(usedNumbers); + for (const number of pool) { + if (!used.has(number)) return number; + } + return null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 64112d720..e38fc4d75 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 163008ecc..6f22f7a83 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3928376ce..6cf009562 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 { + 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 { 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) => ({ diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts new file mode 100644 index 000000000..4ad52a226 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index 9099824d5..fde6d75c6 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts index f11052727..19ed7ea73 100644 --- a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -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], }) diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 669ea2153..224c72892 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 462c97f9a..05b976c85 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -93,6 +93,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Booking requests", href: "/dashboard/booking-requests", icon: , + permission: FREIGHT_PERMS.bookings.view, }, { label: "Customers", @@ -367,7 +368,17 @@ const App = () => { } /> } /> - } /> + + + + } + /> { /> } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> } + element={ + + + + } /> (null); const [routeId, setRouteId] = useState(""); const scheduleDate = booking.scheduledDate; - const [locomotiveId, setLocomotiveId] = useState(""); + const [locomotiveIds, setLocomotiveIds] = useState([]); const [extraBookingIds, setExtraBookingIds] = useState([]); const [forceAssign, setForceAssign] = useState(false); const [previewResult, setPreviewResult] = useState(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 => { 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 /> - ({ 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" } diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 3195281b2..cf3c43196 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -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; diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 284497af2..761f59e08 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -478,7 +478,7 @@ export function AppLayout({ } color="edr-green" - onClick={() => navigate("/bookings/new")} + onClick={() => navigate("/bookings/new", { state: { fresh: true } })} > New Booking diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx index 3aaff33ab..ae7aa6c02 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx @@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({ - + {} : 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 ? ( navigate("/bookings/new")} + onRebook={() => navigate("/bookings/new", { state: { fresh: true } })} /> ) : isPendingConsolidation ? ( } @@ -826,6 +827,7 @@ export default function MyBookings() {