mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1931 lines
71 KiB
TypeScript
1931 lines
71 KiB
TypeScript
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||
import { WagonStatus } from '@edr/types';
|
||
|
||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||
import { TrainSchedulingService } from './train-scheduling.service';
|
||
|
||
const nw5 = {
|
||
id: 'wagon-type-1',
|
||
code: 'NW5',
|
||
name: 'Flat Wagon',
|
||
capacityTons: 70,
|
||
lengthMeters: 14,
|
||
supportedLoadTypes: ['CONTAINER'],
|
||
isActive: true,
|
||
supportsContainer: true,
|
||
};
|
||
|
||
const locomotive = {
|
||
id: 'loc-1',
|
||
code: 'LOC-001',
|
||
maxPullWeightTons: 3500,
|
||
maxTrainLengthMeters: 760,
|
||
status: 'AVAILABLE',
|
||
currentYardId: 'yard-origin',
|
||
};
|
||
|
||
const cw3 = {
|
||
id: 'wagon-type-bulk',
|
||
code: 'CW3',
|
||
name: 'Covered Wagon',
|
||
capacityTons: 60,
|
||
lengthMeters: 14,
|
||
supportedLoadTypes: ['BULK'],
|
||
isActive: true,
|
||
supportsContainer: false,
|
||
};
|
||
|
||
const makeBooking = (
|
||
id: string,
|
||
reference: string,
|
||
weight: number,
|
||
quantity: number,
|
||
containerCode: string,
|
||
wagonsRequired: number,
|
||
scheduledDate = '2026-06-20T08:00:00.000Z',
|
||
originYardId = 'yard-origin',
|
||
destinationYardId = 'yard-destination',
|
||
extra: Record<string, unknown> = {},
|
||
) => ({
|
||
id,
|
||
reference,
|
||
freightType: 'CONTAINER',
|
||
cargoTotalWeightVgm: weight,
|
||
scheduledDate: new Date(scheduledDate),
|
||
originYardId,
|
||
destinationYardId,
|
||
status: 'PAID',
|
||
schedulingStatus: 'HOLDING',
|
||
holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||
company: { companyName: 'Demo Customer' },
|
||
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
|
||
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
|
||
bookingContainers: [
|
||
{
|
||
id: `${id}-line`,
|
||
containerTypeId: 'ct-1',
|
||
quantity,
|
||
wagonsRequired,
|
||
vgmPerUnitTons: weight / quantity,
|
||
isOverweight: false,
|
||
containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] },
|
||
},
|
||
],
|
||
...extra,
|
||
});
|
||
|
||
describe('TrainSchedulingService', () => {
|
||
let service: TrainSchedulingService;
|
||
let dataSource: {
|
||
getRepository: jest.Mock;
|
||
transaction: jest.Mock;
|
||
query: jest.Mock;
|
||
manager: { getRepository: jest.Mock };
|
||
};
|
||
let bookingsRepository: Record<string, jest.Mock>;
|
||
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||
let wagonTypesRepository: { findAll: jest.Mock };
|
||
let trainSchedulesRepository: Record<string, jest.Mock>;
|
||
let trainScheduleBookingsRepository: Record<string, jest.Mock>;
|
||
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
|
||
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
|
||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
|
||
|
||
beforeEach(() => {
|
||
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||
// to "no sibling schedules" so isolated unit tests don't need to wire it.
|
||
const emptySiblingQb = {
|
||
where: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([]),
|
||
};
|
||
dataSource = {
|
||
getRepository: jest.fn(),
|
||
transaction: jest.fn(),
|
||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||
query: jest.fn().mockResolvedValue([]),
|
||
manager: {
|
||
getRepository: jest.fn(() => ({
|
||
createQueryBuilder: jest.fn(() => emptySiblingQb),
|
||
})),
|
||
},
|
||
};
|
||
bookingsRepository = {
|
||
findEligibleForScheduling: jest.fn(),
|
||
findByIdsForScheduling: jest.fn(),
|
||
findAll: jest.fn(),
|
||
updateSchedulingFields: jest.fn(),
|
||
};
|
||
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
|
||
wagonTypesRepository = { findAll: jest.fn() };
|
||
trainSchedulesRepository = {
|
||
findById: jest.fn(),
|
||
findByIdWithFullGraph: jest.fn(),
|
||
findAll: jest.fn(),
|
||
updateStatus: jest.fn(),
|
||
update: jest.fn(),
|
||
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||
};
|
||
trainScheduleBookingsRepository = {
|
||
findByBookingIds: jest.fn(),
|
||
findByScheduleId: jest.fn().mockResolvedValue([]),
|
||
createMany: jest.fn(),
|
||
deleteByScheduleAndBooking: jest.fn(),
|
||
};
|
||
wagonBookingAllocationsRepository = {
|
||
deleteByTrainSetId: jest.fn().mockResolvedValue([]),
|
||
createMany: jest.fn(),
|
||
};
|
||
wagonAllocationContainerItemsRepository = {
|
||
createMany: jest.fn(),
|
||
deleteByAllocationIds: jest.fn(),
|
||
findAll: jest.fn().mockResolvedValue([]),
|
||
};
|
||
wagonAllocationBulkLoadsRepository = {
|
||
createMany: jest.fn(),
|
||
deleteByAllocationIds: jest.fn(),
|
||
findAll: jest.fn().mockResolvedValue([]),
|
||
};
|
||
|
||
trainCheckpointEventsRepository = {
|
||
findBySchedule: jest.fn().mockResolvedValue([]),
|
||
findAll: jest.fn().mockResolvedValue([]),
|
||
create: jest.fn(),
|
||
update: jest.fn(),
|
||
};
|
||
|
||
service = new TrainSchedulingService(
|
||
dataSource as never,
|
||
bookingsRepository as never,
|
||
locomotivesRepository as never,
|
||
wagonTypesRepository as never,
|
||
trainSchedulesRepository as never,
|
||
trainScheduleBookingsRepository as never,
|
||
wagonBookingAllocationsRepository as never,
|
||
wagonAllocationContainerItemsRepository as never,
|
||
wagonAllocationBulkLoadsRepository as never,
|
||
trainCheckpointEventsRepository as never,
|
||
{ create: jest.fn() } as never, // trainCompositionRemovalLogRepository
|
||
{
|
||
autoUnloadArrivedBookings: jest.fn(),
|
||
autoUnloadExportAtDjibouti: jest.fn(),
|
||
} as never,
|
||
{
|
||
htmlToPdfBuffer: jest.fn(),
|
||
} as never,
|
||
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||
{
|
||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||
} as never, // bookingJourneyService
|
||
{ dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier
|
||
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
|
||
);
|
||
|
||
const defaultFleetWagons = [
|
||
...Array.from({ length: 100 }, (_, index) => ({
|
||
id: `wagon-nw5-${index}`,
|
||
wagonTypeId: nw5.id,
|
||
status: WagonStatus.Available,
|
||
currentYardId: 'yard-origin',
|
||
currentTrainScheduleId: null,
|
||
})),
|
||
...Array.from({ length: 50 }, (_, index) => ({
|
||
id: `wagon-cw3-${index}`,
|
||
wagonTypeId: cw3.id,
|
||
status: WagonStatus.Available,
|
||
currentYardId: 'yard-origin',
|
||
currentTrainScheduleId: null,
|
||
})),
|
||
];
|
||
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === Wagon) {
|
||
return { find: jest.fn().mockResolvedValue(defaultFleetWagons) };
|
||
}
|
||
if (entity === WagonType) {
|
||
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
|
||
}
|
||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||
});
|
||
});
|
||
|
||
it('returns fleet availability and defers bookings when fleet is insufficient', async () => {
|
||
const bookings = [
|
||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
|
||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
|
||
];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const availableWagons = Array.from({ length: 15 }, (_, index) => ({
|
||
id: `wagon-${index}`,
|
||
wagonTypeId: nw5.id,
|
||
status: WagonStatus.Available,
|
||
currentYardId: 'yard-origin',
|
||
currentTrainScheduleId: null,
|
||
}));
|
||
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === Wagon) {
|
||
return { find: jest.fn().mockResolvedValue(availableWagons) };
|
||
}
|
||
if (entity === WagonType) {
|
||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||
}
|
||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||
});
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: bookings.map((b) => b.id),
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
// Availability rows now report what the BOUNDED plan actually uses per
|
||
// type (never more than stock, so no shortfall on the rows themselves);
|
||
// the shortage is carried by the deferred bookings' own shortage rows.
|
||
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
|
||
expect(
|
||
result.fleetAvailability?.every((row) => row.needed <= row.available),
|
||
).toBe(true);
|
||
expect(result.deferredBookings?.length).toBeGreaterThan(0);
|
||
expect(result.deferredBookings?.[0]?.reason).toContain('short');
|
||
expect(result.summary.wagonsNeeded).toBeLessThan(30);
|
||
expect(result.warnings.some((w) => w.includes('deferred'))).toBe(true);
|
||
});
|
||
|
||
it('computes slot-based preview for Group A', async () => {
|
||
const bookings = [
|
||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
|
||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
|
||
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15),
|
||
];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: bookings.map((b) => b.id),
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
expect(result.violations).toEqual([]);
|
||
// TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) —
|
||
// the planner packs by container size, not the stored per-line fallback.
|
||
expect(result.summary.wagonsNeeded).toBe(40);
|
||
expect(result.wagonPlan).toHaveLength(40);
|
||
});
|
||
|
||
it('returns soft hold warnings without forceAssign', async () => {
|
||
const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: ['b7'],
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
expect(result.warnings.length).toBeGreaterThan(0);
|
||
expect(result.warnings[0]).toContain('soft hold window');
|
||
});
|
||
|
||
it('warns on the overweight booking but still allows scheduling', async () => {
|
||
const bookings = [
|
||
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
|
||
bookingContainers: [
|
||
{
|
||
id: 'b6-line',
|
||
containerTypeId: 'ct-1',
|
||
quantity: 80,
|
||
wagonsRequired: 80,
|
||
vgmPerUnitTons: 45,
|
||
isOverweight: true,
|
||
containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] },
|
||
},
|
||
],
|
||
}),
|
||
];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: ['b6'],
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
expect(result.violations.some((v) => v.includes('overweight'))).toBe(false);
|
||
expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true);
|
||
});
|
||
|
||
it('allows preview when bookings are already on the target schedule', async () => {
|
||
// A booking already pinned to the target schedule is exempt from the
|
||
// corridor/day/status gates — mark it so on the entity, matching the link row.
|
||
const bookings = [
|
||
{ ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
|
||
];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([
|
||
{ bookingId: 'b1', trainScheduleId: 'sched-target' },
|
||
]);
|
||
trainSchedulesRepository.findById.mockResolvedValue({
|
||
id: 'sched-target',
|
||
direction: 'IMPORT',
|
||
});
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: ['b1'],
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
targetScheduleId: 'sched-target',
|
||
});
|
||
|
||
expect(result.violations).not.toContain(
|
||
'One or more selected bookings are already assigned to a train schedule',
|
||
);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it('flags a booking scheduled for a different day than the train departure', async () => {
|
||
// The old cross-booking "must share the same schedule date" rule is gone;
|
||
// the live rule is that every booking must match the departure day. b2
|
||
// departs a day later, so it's the one flagged.
|
||
const bookings = [
|
||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
||
];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: bookings.map((b) => b.id),
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
expect(result.violations).not.toContain(
|
||
'Selected bookings must share the same schedule date',
|
||
);
|
||
expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it('rejects bookings that are not in schedulable status', async () => {
|
||
const bookings = [
|
||
{ ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
|
||
];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: ['b7'],
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
expect(result.valid).toBe(false);
|
||
expect(result.violations).toContain(
|
||
'Only PAID bookings can be scheduled; received: APPROVED',
|
||
);
|
||
});
|
||
|
||
it('creates a schedule transactionally when validation passes', async () => {
|
||
const route = {
|
||
id: 'route-1',
|
||
name: 'Djibouti to Addis',
|
||
originYardId: 'yard-origin',
|
||
destinationYardId: 'yard-destination',
|
||
isActive: true,
|
||
status: 'AVAILABLE',
|
||
direction: 'IMPORT',
|
||
};
|
||
|
||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||
const lockedLocomotiveRepo = {
|
||
findOne: jest
|
||
.fn()
|
||
.mockResolvedValueOnce(locomotive)
|
||
.mockResolvedValueOnce(locomotive2),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const trainScheduleRepo = {
|
||
create: jest.fn().mockImplementation((value) => value),
|
||
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
||
// findGroupWindowAnchor looks for same-day sibling schedules; none here.
|
||
createQueryBuilder: jest.fn(() => ({
|
||
where: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([]),
|
||
})),
|
||
};
|
||
const trainSetRepo = {
|
||
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) {
|
||
case 'Locomotive':
|
||
return lockedLocomotiveRepo;
|
||
case 'TrainSchedule':
|
||
return trainScheduleRepo;
|
||
case 'TrainSet':
|
||
return trainSetRepo;
|
||
case 'TrainSetLocomotive':
|
||
return trainSetLocomotiveRepo;
|
||
default:
|
||
throw new Error(`Unexpected transaction repository ${entity?.name}`);
|
||
}
|
||
}),
|
||
};
|
||
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if ((entity as { name?: string })?.name === 'Route') {
|
||
return { findOne: jest.fn().mockResolvedValue(route) };
|
||
}
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === WagonType) {
|
||
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
|
||
}
|
||
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
|
||
});
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
|
||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||
callback(manager),
|
||
);
|
||
|
||
// Departure must clear the import lead window (≥ importWindowLeadDays ahead
|
||
// of now), so use a comfortably-future date rather than a hardcoded one.
|
||
const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
||
const result = await service.createContainerTrainSchedule({
|
||
routeId: 'route-1',
|
||
scheduleDate: futureDeparture,
|
||
locomotiveIds: ['loc-1', 'loc-2'],
|
||
});
|
||
|
||
expect(trainSetRepo.save).toHaveBeenCalled();
|
||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||
// Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
|
||
// one locomotive may sit on several future schedules.
|
||
expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
|
||
expect(result.id).toBe('schedule-1');
|
||
});
|
||
|
||
it('previews mixed container and bulk bookings', async () => {
|
||
const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2);
|
||
const bulkBooking = {
|
||
id: 'b1',
|
||
reference: 'BKG-BULK',
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: 120,
|
||
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
|
||
originYardId: 'yard-origin',
|
||
destinationYardId: 'yard-destination',
|
||
status: 'PAID',
|
||
bookingContainers: [],
|
||
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
|
||
};
|
||
|
||
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
||
if (where?.code === 'NW5') return [nw5];
|
||
return [nw5, cw3];
|
||
});
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewTrainSchedule({
|
||
bookingIds: ['c1', 'b1'],
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
// Mixed freight now labels the summary by the concrete wagon type codes it uses.
|
||
expect(result.summary.wagonType).toContain('NW5');
|
||
expect(result.summary.wagonType).toContain('CW3');
|
||
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
||
expect(result.containerUnits).toHaveLength(2);
|
||
});
|
||
|
||
it('previews container bookings without requiring placements', async () => {
|
||
const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)];
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
|
||
const result = await service.previewTrainSchedule({
|
||
bookingIds: ['c2'],
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
expect(result.containerUnits).toHaveLength(1);
|
||
});
|
||
|
||
it('rejects create when the locked locomotive is no longer available', async () => {
|
||
// Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
|
||
// non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
|
||
const manager = {
|
||
getRepository: jest.fn(() => ({
|
||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
|
||
})),
|
||
};
|
||
|
||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||
if (entity?.name === 'Route') {
|
||
return {
|
||
findOne: jest.fn().mockResolvedValue({
|
||
id: 'route-1',
|
||
name: 'Djibouti to Addis',
|
||
originYardId: 'yard-origin',
|
||
destinationYardId: 'yard-destination',
|
||
isActive: true,
|
||
status: 'AVAILABLE',
|
||
direction: 'IMPORT',
|
||
}),
|
||
};
|
||
}
|
||
throw new Error(`Unexpected repository ${entity?.name}`);
|
||
});
|
||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||
callback(manager),
|
||
);
|
||
|
||
await expect(
|
||
service.createContainerTrainSchedule({
|
||
routeId: 'route-1',
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
locomotiveIds: ['loc-1', 'loc-2'],
|
||
}),
|
||
).rejects.toBeInstanceOf(ConflictException);
|
||
});
|
||
|
||
it('rejects pin when wagon is not at the schedule origin yard', async () => {
|
||
const scheduleId = 'sched-1';
|
||
const slotId = 'slot-1';
|
||
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||
id: scheduleId,
|
||
status: 'DRAFT',
|
||
originStationId: 'yard-origin',
|
||
trainSet: {
|
||
wagons: [{ id: slotId, physicalWagonId: null }],
|
||
},
|
||
});
|
||
|
||
const manager = {
|
||
getRepository: jest.fn((entity: { name?: string }) => {
|
||
if (entity === Wagon) {
|
||
return {
|
||
findOne: jest.fn().mockResolvedValue({
|
||
id: 'wagon-1',
|
||
wagonNumber: 'WGN-001',
|
||
status: WagonStatus.Available,
|
||
currentYardId: 'yard-other',
|
||
currentTrainScheduleId: null,
|
||
}),
|
||
update: jest.fn(),
|
||
};
|
||
}
|
||
if (entity === TrainSetWagon) {
|
||
return { update: jest.fn() };
|
||
}
|
||
throw new Error(`Unexpected repository ${entity?.name}`);
|
||
}),
|
||
};
|
||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<void>) =>
|
||
callback(manager),
|
||
);
|
||
|
||
await expect(
|
||
service.pinWagons(scheduleId, {
|
||
assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
|
||
}),
|
||
).rejects.toBeInstanceOf(ConflictException);
|
||
});
|
||
|
||
it('flags physical fleet shortfall when wagons are not at the origin yard', async () => {
|
||
const exportBooking = makeBooking(
|
||
'exp-1',
|
||
'BKG-EXP',
|
||
50,
|
||
1,
|
||
'40FT',
|
||
1,
|
||
'2026-06-20T08:00:00.000Z',
|
||
'yard-addis',
|
||
'yard-djibouti',
|
||
{
|
||
originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
|
||
destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
|
||
},
|
||
);
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([
|
||
{ ...locomotive, currentYardId: 'yard-addis' },
|
||
]);
|
||
|
||
const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({
|
||
id: `wagon-nw5-${index}`,
|
||
wagonTypeId: nw5.id,
|
||
wagonNumber: `WGN-${index}`,
|
||
status: WagonStatus.Available,
|
||
currentYardId: 'yard-djibouti',
|
||
currentTrainScheduleId: null,
|
||
}));
|
||
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === Wagon) {
|
||
return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
|
||
}
|
||
if (entity === WagonType) {
|
||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||
}
|
||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||
});
|
||
|
||
const result = await service.previewContainerTrainSchedule({
|
||
bookingIds: ['exp-1'],
|
||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||
originStationId: 'yard-addis',
|
||
destinationStationId: 'yard-djibouti',
|
||
});
|
||
|
||
// List-based planner: a booking with no plannable wagon at the yard is
|
||
// DEFERRED with the wagon-type reason (assign still hard-fails when no
|
||
// booking fits), instead of surfacing a phantom-slot violation.
|
||
expect(result.valid).toBe(true);
|
||
expect(result.wagonPlan).toHaveLength(0);
|
||
expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true);
|
||
});
|
||
|
||
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
|
||
const scheduleId = 'sched-assign-1';
|
||
const trainSetId = 'train-set-1';
|
||
const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);
|
||
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
trainSchedulesRepository.findById.mockResolvedValue({
|
||
id: scheduleId,
|
||
direction: 'IMPORT',
|
||
});
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||
id: scheduleId,
|
||
status: 'DRAFT',
|
||
direction: 'IMPORT',
|
||
trainSetId,
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||
trainSet: {
|
||
id: trainSetId,
|
||
locomotive,
|
||
wagons: [],
|
||
},
|
||
scheduleBookings: [],
|
||
});
|
||
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === Wagon) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === WagonType) {
|
||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||
}
|
||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||
});
|
||
|
||
const wagonRepo = {
|
||
find: jest.fn().mockResolvedValue([]),
|
||
update: jest.fn(),
|
||
};
|
||
const trainSetWagonRepo = {
|
||
delete: jest.fn(),
|
||
create: jest.fn((v) => v),
|
||
save: jest.fn(async (rows) =>
|
||
rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
|
||
...r,
|
||
id: `slot-${i + 1}`,
|
||
})),
|
||
),
|
||
update: jest.fn(),
|
||
};
|
||
|
||
const manager = {
|
||
getRepository: jest.fn((entity: unknown) => {
|
||
if (entity === Wagon) return wagonRepo;
|
||
if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
|
||
if (entity === TrainSetWagon) return trainSetWagonRepo;
|
||
if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
|
||
if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
|
||
if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
|
||
return {
|
||
create: jest.fn((v) => v),
|
||
save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
|
||
delete: jest.fn(),
|
||
};
|
||
}
|
||
return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
|
||
}),
|
||
};
|
||
dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
|
||
cb(manager),
|
||
);
|
||
|
||
await expect(
|
||
service.assignBookingsToSchedule(
|
||
scheduleId,
|
||
{ bookingIds: ['b-pin'], containerPlacements: [] },
|
||
'CONTAINER',
|
||
),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
});
|
||
|
||
describe('restampPendingWindows (hand-configured windows are exempt)', () => {
|
||
const future = new Date(Date.now() + 30 * 24 * 3600_000);
|
||
const update = jest.fn();
|
||
|
||
beforeEach(() => {
|
||
update.mockClear();
|
||
// Global rules read + the TrainSchedule repo the restamp writes through.
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
const name = (entity as { name?: string })?.name;
|
||
if (name === 'TrainSchedulingGlobalRules') {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
return { update };
|
||
});
|
||
});
|
||
|
||
it('re-stamps a schedule that follows the global rules', async () => {
|
||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||
{
|
||
id: 'sched-global',
|
||
direction: 'IMPORT',
|
||
scheduledDepartureDate: future,
|
||
windowRuleCustom: false,
|
||
},
|
||
]);
|
||
await expect(service.restampPendingWindows()).resolves.toBe(1);
|
||
expect(update).toHaveBeenCalledWith('sched-global', expect.anything());
|
||
});
|
||
|
||
it('leaves a hand-configured schedule alone', async () => {
|
||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||
{
|
||
id: 'sched-custom',
|
||
direction: 'IMPORT',
|
||
scheduledDepartureDate: future,
|
||
windowRuleCustom: true,
|
||
},
|
||
]);
|
||
// Staff picked these times deliberately — a global-rules edit must not
|
||
// overwrite them, or the per-schedule configuration would be pointless.
|
||
await expect(service.restampPendingWindows()).resolves.toBe(0);
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('getUnassignedBookings', () => {
|
||
const scheduleId = 'sched-unassigned-1';
|
||
const trainSetId = 'train-set-unassigned';
|
||
const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1);
|
||
const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1);
|
||
|
||
const buildScheduleGraph = () => ({
|
||
id: scheduleId,
|
||
status: 'DRAFT',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||
trainSet: {
|
||
id: trainSetId,
|
||
locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' },
|
||
wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }],
|
||
},
|
||
scheduleBookings: [],
|
||
});
|
||
|
||
beforeEach(() => {
|
||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||
bookingsRepository.findAll.mockResolvedValue([
|
||
{
|
||
...assignedBooking,
|
||
trainScheduleId: scheduleId,
|
||
paymentStatus: 'PAID',
|
||
isGovernment: false,
|
||
},
|
||
{
|
||
...unassignedBooking,
|
||
trainScheduleId: scheduleId,
|
||
paymentStatus: 'PAID',
|
||
isGovernment: false,
|
||
},
|
||
]);
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph());
|
||
});
|
||
|
||
it('allows assign when train slots are full but origin yard has matching wagons', async () => {
|
||
const yardFleet = [
|
||
{
|
||
id: 'wagon-pinned',
|
||
wagonTypeId: nw5.id,
|
||
status: WagonStatus.Assigned,
|
||
currentYardId: 'yard-origin',
|
||
currentTrainScheduleId: scheduleId,
|
||
},
|
||
...Array.from({ length: 2 }, (_, index) => ({
|
||
id: `wagon-yard-${index}`,
|
||
wagonTypeId: nw5.id,
|
||
status: WagonStatus.Available,
|
||
currentYardId: 'yard-origin',
|
||
currentTrainScheduleId: null,
|
||
})),
|
||
];
|
||
|
||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||
const map = new Map([
|
||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||
]);
|
||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||
});
|
||
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === Wagon) {
|
||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||
}
|
||
if (entity === WagonType) {
|
||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||
}
|
||
if (entity === WagonBookingAllocation) {
|
||
return {
|
||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||
};
|
||
}
|
||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||
});
|
||
|
||
const result = await service.getUnassignedBookings(scheduleId);
|
||
|
||
expect(result.bookings).toHaveLength(1);
|
||
expect(result.bookings[0].id).toBe(unassignedBooking.id);
|
||
expect(result.bookings[0].canAssign).toBe(true);
|
||
expect(result.bookings[0].blockReason).toBeNull();
|
||
expect(
|
||
result.fleetAtOrigin.some(
|
||
(row: { wagonTypeCode: string; available: number }) =>
|
||
row.wagonTypeCode === 'NW5' && row.available >= 2,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('blocks assign when origin yard lacks wagons of the required type', async () => {
|
||
const yardFleet = [
|
||
{
|
||
id: 'wagon-pinned',
|
||
wagonTypeId: nw5.id,
|
||
status: WagonStatus.Assigned,
|
||
currentYardId: 'yard-origin',
|
||
currentTrainScheduleId: scheduleId,
|
||
},
|
||
];
|
||
|
||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||
const map = new Map([
|
||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||
]);
|
||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||
});
|
||
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === Wagon) {
|
||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||
}
|
||
if (entity === WagonType) {
|
||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||
}
|
||
if (entity === WagonBookingAllocation) {
|
||
return {
|
||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||
};
|
||
}
|
||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||
});
|
||
|
||
const result = await service.getUnassignedBookings(scheduleId);
|
||
|
||
expect(result.bookings).toHaveLength(1);
|
||
expect(result.bookings[0].canAssign).toBe(false);
|
||
expect(result.bookings[0].blockReason).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe('getAvailableLocomotivesForRoute', () => {
|
||
it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
|
||
const routeId = 'route-export';
|
||
const originYardId = 'yard-addis';
|
||
const routeRepo = {
|
||
findOne: jest.fn().mockResolvedValue({
|
||
id: routeId,
|
||
name: 'Addis → Djibouti',
|
||
isActive: true,
|
||
status: 'AVAILABLE',
|
||
originYardId,
|
||
originYard: { country: 'Ethiopia' },
|
||
destinationYard: { country: 'Djibouti' },
|
||
}),
|
||
};
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||
return { findOne: jest.fn(), update: jest.fn() };
|
||
});
|
||
// Advance-scheduling picker: nothing is filtered by yard — every in-service
|
||
// locomotive is returned and annotated with whether it's at the origin yet.
|
||
locomotivesRepository.findAll.mockResolvedValue([
|
||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
|
||
]);
|
||
|
||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||
|
||
expect(result).toHaveLength(2);
|
||
expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
|
||
expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
|
||
});
|
||
|
||
it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
|
||
const routeId = 'route-domestic';
|
||
const originYardId = 'yard-addis';
|
||
const routeRepo = {
|
||
findOne: jest.fn().mockResolvedValue({
|
||
id: routeId,
|
||
name: 'Addis → Dire Dawa',
|
||
isActive: true,
|
||
status: 'AVAILABLE',
|
||
originYardId,
|
||
originYard: { country: 'Ethiopia' },
|
||
destinationYard: { country: 'Ethiopia' },
|
||
}),
|
||
};
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||
return { findOne: jest.fn(), update: jest.fn() };
|
||
});
|
||
|
||
await expect(
|
||
service.getAvailableLocomotivesForRoute(routeId),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
});
|
||
});
|
||
|
||
describe('marshalling documents', () => {
|
||
// Staff check these against the physical consist, so every wagon on the
|
||
// train set has to appear — an empty wagon that renders no row reads as a
|
||
// wagon that is not on the train.
|
||
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||
sequenceNo,
|
||
wagonNumber,
|
||
physicalWagon: { wagonNumber },
|
||
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||
lengthMeters: 14,
|
||
capacityTons: 70,
|
||
allocations,
|
||
});
|
||
|
||
const loadedAllocation = {
|
||
bookingId: 'booking-1',
|
||
bookingReference: 'BK-2026-000001',
|
||
loadType: 'CONTAINER',
|
||
allocatedWeightTons: 24.5,
|
||
containerNumbers: ['CONT-001'],
|
||
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
|
||
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
|
||
};
|
||
|
||
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
|
||
|
||
it('lists an empty wagon on the export document and marks it EMPTY', () => {
|
||
const schedule = {
|
||
id: 'schedule-1',
|
||
trainNumber: '8302',
|
||
direction: 'EXPORT',
|
||
trainSet: {
|
||
wagons: [
|
||
makeWagon(1, 'W-001', [loadedAllocation]),
|
||
makeWagon(2, 'W-002', []),
|
||
makeWagon(3, 'W-003', []),
|
||
],
|
||
},
|
||
scheduleBookings: [],
|
||
};
|
||
|
||
const html = (service as never as {
|
||
buildExportLoadListHtml: (s: unknown) => string;
|
||
}).buildExportLoadListHtml(schedule);
|
||
|
||
expect(countRows(html)).toBe(3);
|
||
expect(html).toContain('W-002');
|
||
expect(html).toContain('W-003');
|
||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
|
||
// The wagon count must agree with the rows the reader can see.
|
||
expect(html).toContain('3 (2 empty)');
|
||
});
|
||
|
||
it('lists an empty wagon on the import document and marks it EMPTY', () => {
|
||
const loadList = {
|
||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||
trainScheduleId: 'schedule-1',
|
||
trainNumber: '8002',
|
||
route: 'Djibouti → Indode',
|
||
origin: 'Djibouti Port',
|
||
destination: 'Indode',
|
||
totalBookings: 1,
|
||
wagons: [
|
||
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
|
||
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
|
||
],
|
||
operation: { status: {} },
|
||
};
|
||
|
||
const html = (service as never as {
|
||
buildImportLoadListHtml: (l: unknown) => string;
|
||
}).buildImportLoadListHtml(loadList);
|
||
|
||
expect(countRows(html)).toBe(2);
|
||
expect(html).toContain('W-002');
|
||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||
expect(html).toContain('2 (1 empty)');
|
||
});
|
||
|
||
it('lists loaded empty containers by number and states they are empty', () => {
|
||
const schedule = {
|
||
id: 'schedule-1',
|
||
trainNumber: '8301',
|
||
direction: 'EXPORT',
|
||
trainSet: {
|
||
wagons: [makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', []), makeWagon(3, 'W-003', [])],
|
||
},
|
||
scheduleBookings: [],
|
||
};
|
||
|
||
const html = (service as never as {
|
||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||
}).buildExportLoadListHtml(schedule, {
|
||
emptyContainers: [
|
||
{ containerNumber: 'CMU9876543', containerSize: '40', wagonSequenceNo: 1 },
|
||
{ containerNumber: 'TEMU1112223', containerSize: '20', wagonSequenceNo: 2 },
|
||
{ containerNumber: 'TEMU4445556', containerSize: '20', wagonSequenceNo: 2 },
|
||
],
|
||
});
|
||
|
||
expect(html).toContain('CMU9876543');
|
||
expect(html).toContain('TEMU1112223, TEMU4445556');
|
||
expect(html.match(/EMPTY CONTAINER/g)).toHaveLength(2);
|
||
// Wagon 3 carries nothing at all, so it keeps the bare-wagon wording.
|
||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||
expect(html).toContain('3 (1 empty)');
|
||
expect(html).toContain('<span>Empty containers</span><strong>3</strong>');
|
||
});
|
||
|
||
it('renders wagons in consist order regardless of the order the relation returns', () => {
|
||
const schedule = {
|
||
id: 'schedule-1',
|
||
trainNumber: '8302',
|
||
direction: 'EXPORT',
|
||
trainSet: {
|
||
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
|
||
},
|
||
scheduleBookings: [],
|
||
};
|
||
|
||
const html = (service as never as {
|
||
buildExportLoadListHtml: (s: unknown) => string;
|
||
}).buildExportLoadListHtml(schedule);
|
||
|
||
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
|
||
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
|
||
});
|
||
|
||
it('omits the empty-count suffix when every wagon is loaded', () => {
|
||
const schedule = {
|
||
id: 'schedule-1',
|
||
trainNumber: '8302',
|
||
direction: 'EXPORT',
|
||
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
|
||
scheduleBookings: [],
|
||
};
|
||
|
||
const html = (service as never as {
|
||
buildExportLoadListHtml: (s: unknown) => string;
|
||
}).buildExportLoadListHtml(schedule);
|
||
|
||
expect(html).not.toContain('empty)');
|
||
expect(html).not.toContain('EMPTY');
|
||
});
|
||
|
||
// ---- intercity marshalling (Marshalling 2): the current on-board view ----
|
||
|
||
const onBoardView = (schedule: unknown) =>
|
||
(service as never as {
|
||
intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] };
|
||
}).intercityOnBoardView(schedule);
|
||
|
||
const buildWithOpts = (schedule: unknown, opts: unknown) =>
|
||
(service as never as {
|
||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||
}).buildExportLoadListHtml(schedule, opts);
|
||
|
||
const allocWith = (over: Record<string, unknown>) => ({ ...loadedAllocation, ...over });
|
||
|
||
it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => {
|
||
const schedule = {
|
||
trainSet: {
|
||
wagons: [
|
||
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' },
|
||
{
|
||
...makeWagon(3, 'W-003', [
|
||
allocWith({ status: 'LOADED', bookingId: 'booking-3' }),
|
||
allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }),
|
||
]),
|
||
status: 'RESERVED',
|
||
},
|
||
],
|
||
},
|
||
scheduleBookings: [],
|
||
};
|
||
|
||
const { wagons } = onBoardView(schedule);
|
||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||
(w) => w.physicalWagon.wagonNumber,
|
||
);
|
||
expect(numbers).toEqual(['W-001', 'W-003']);
|
||
const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find(
|
||
(w) => w.physicalWagon.wagonNumber === 'W-003',
|
||
);
|
||
expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']);
|
||
});
|
||
|
||
it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => {
|
||
const schedule = {
|
||
id: 'schedule-1',
|
||
trainNumber: '8302',
|
||
direction: 'EXPORT',
|
||
trainSet: {
|
||
wagons: [
|
||
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' },
|
||
],
|
||
},
|
||
scheduleBookings: [],
|
||
};
|
||
|
||
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||
const html = buildWithOpts(schedule, { wagons, unassignedBookings });
|
||
expect(html).toContain('W-002');
|
||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||
expect(html).toContain('2 (1 empty)');
|
||
});
|
||
|
||
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
|
||
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||
const schedule = {
|
||
trainSet: { wagons: [legWagonEmpty, legWagonLoaded] },
|
||
scheduleBookings: [],
|
||
};
|
||
|
||
const { wagons } = onBoardView(schedule);
|
||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||
(w) => w.physicalWagon.wagonNumber,
|
||
);
|
||
expect(numbers).toEqual(['W-LEG2']);
|
||
});
|
||
|
||
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
||
const rider = {
|
||
id: 'booking-9',
|
||
reference: 'BK-2026-000009',
|
||
status: 'IN_TRANSIT',
|
||
company: { name: 'Rider Co' },
|
||
cargoType: { cargoTypeName: 'Cement', code: 'CEM' },
|
||
originYard: { label: 'Adama' },
|
||
destinationYard: { label: 'Dire Dawa' },
|
||
bookingContainers: [{ containerNumber: 'RIDE-001' }],
|
||
};
|
||
const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' };
|
||
const schedule = {
|
||
id: 'schedule-1',
|
||
trainNumber: '8302',
|
||
direction: 'EXPORT',
|
||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] },
|
||
scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }],
|
||
};
|
||
|
||
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||
expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']);
|
||
|
||
const html = buildWithOpts(schedule, {
|
||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||
positionLabel: 'After Dire Dawa',
|
||
wagons,
|
||
unassignedBookings,
|
||
});
|
||
expect(html).toContain('ON BOARD — WAGON NOT RECORDED');
|
||
expect(html).toContain('BK-2026-000009');
|
||
expect(html).toContain('RIDE-001');
|
||
expect(html).not.toContain('BK-2026-000008');
|
||
expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)');
|
||
expect(html).toContain('After Dire Dawa');
|
||
});
|
||
|
||
it('rejects the intercity marshalling document for a train that has not been dispatched', async () => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||
id: 'schedule-1',
|
||
status: 'SCHEDULED',
|
||
});
|
||
await expect(
|
||
service.intercityMarshallingDocument('schedule-1'),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
});
|
||
});
|
||
|
||
describe('moveWagonLoad — staff rearrange', () => {
|
||
const containerType = {
|
||
code: 'NX70',
|
||
supportedLoadTypes: ['CONTAINER'],
|
||
supportsContainer: true,
|
||
};
|
||
let slotA: Record<string, unknown>;
|
||
let slotB: Record<string, unknown>;
|
||
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
|
||
let allocRepo: { find: jest.Mock; update: jest.Mock };
|
||
let slotRepo: {
|
||
update: jest.Mock;
|
||
create: jest.Mock;
|
||
save: jest.Mock;
|
||
createQueryBuilder: jest.Mock;
|
||
};
|
||
let wagonRepo: { findOne: jest.Mock };
|
||
|
||
const makeSchedule = (over: Record<string, unknown> = {}) => ({
|
||
id: 'sched-1',
|
||
status: 'SCHEDULED',
|
||
trainSetId: 'ts-1',
|
||
trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
|
||
...over,
|
||
});
|
||
|
||
beforeEach(() => {
|
||
slotA = {
|
||
id: 'wA',
|
||
trainSetId: 'ts-1',
|
||
sequenceNo: 1,
|
||
capacityTons: 61,
|
||
lengthMeters: 14,
|
||
assignedWeightTons: 40,
|
||
status: 'RESERVED',
|
||
boardYardId: 'yard-1',
|
||
alightYardId: null,
|
||
wagonType: containerType,
|
||
};
|
||
slotB = {
|
||
id: 'wB',
|
||
trainSetId: 'ts-1',
|
||
sequenceNo: 2,
|
||
capacityTons: 61,
|
||
lengthMeters: 14,
|
||
assignedWeightTons: 25,
|
||
status: 'RESERVED',
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
wagonType: containerType,
|
||
};
|
||
allocsByWagon = {
|
||
// 20ft pair (two allocations sharing wagon A) — must travel together.
|
||
wA: [
|
||
{ id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||
{ id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||
],
|
||
// one 40ft on wagon B.
|
||
wB: [
|
||
{ id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' },
|
||
],
|
||
};
|
||
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
|
||
allocRepo = {
|
||
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
|
||
Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
|
||
),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
slotRepo = {
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
create: jest.fn((row: Record<string, unknown>) => row),
|
||
save: jest.fn((row: Record<string, unknown>) =>
|
||
Promise.resolve({ id: 'slot-new', ...row }),
|
||
),
|
||
createQueryBuilder: jest.fn(() => ({
|
||
select: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
getRawOne: jest.fn().mockResolvedValue({ maxSequenceNo: 2 }),
|
||
})),
|
||
};
|
||
wagonRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === WagonBookingAllocation) return allocRepo;
|
||
if (entity === TrainSetWagon) return slotRepo;
|
||
if (entity === Wagon) return wagonRepo;
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
});
|
||
dataSource.transaction.mockImplementation(
|
||
async (fn: (m: unknown) => Promise<void>) =>
|
||
fn({ getRepository: dataSource.getRepository }),
|
||
);
|
||
jest
|
||
.spyOn(
|
||
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||
'getTrainScheduleById' as never,
|
||
)
|
||
.mockResolvedValue({ id: 'sched-1' } as never);
|
||
});
|
||
|
||
it('rejects moves on a dispatched train', async () => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||
makeSchedule({ status: 'DISPATCHED' }),
|
||
);
|
||
await expect(
|
||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||
).rejects.toThrow(BadRequestException);
|
||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
|
||
await expect(
|
||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
|
||
).rejects.toThrow(/not part of this schedule/);
|
||
});
|
||
|
||
it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
|
||
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });
|
||
|
||
// The 20ft pair moved together onto wagon B…
|
||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
|
||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
|
||
// …and the 40ft came back to wagon A.
|
||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
|
||
// Load-coupled slot fields follow their loads.
|
||
expect(slotRepo.update).toHaveBeenCalledWith('wB', {
|
||
assignedWeightTons: 40,
|
||
status: 'RESERVED',
|
||
boardYardId: 'yard-1',
|
||
alightYardId: null,
|
||
});
|
||
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||
assignedWeightTons: 25,
|
||
status: 'RESERVED',
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
});
|
||
});
|
||
|
||
it('moves the load onto an empty consist-only wagon without renaming wagons', async () => {
|
||
wagonRepo.findOne.mockResolvedValue({
|
||
id: 'phys-9',
|
||
wagonTypeId: 'wt-1',
|
||
wagonNumber: 'WGN-9',
|
||
wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 },
|
||
});
|
||
|
||
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' });
|
||
|
||
expect(wagonRepo.findOne).toHaveBeenCalledWith(
|
||
expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }),
|
||
);
|
||
// A slot is created ON the target wagon, carrying the source's load
|
||
// fields. sequence_no appends past the existing max so it clears the
|
||
// (train_set_id, sequence_no) unique index.
|
||
expect(slotRepo.save).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
trainSetId: 'ts-1',
|
||
physicalWagonId: 'phys-9',
|
||
wagonTypeId: 'wt-1',
|
||
sequenceNo: 3,
|
||
capacityTons: 70,
|
||
lengthMeters: 14,
|
||
assignedWeightTons: 40,
|
||
status: 'RESERVED',
|
||
boardYardId: 'yard-1',
|
||
alightYardId: null,
|
||
}),
|
||
);
|
||
// The whole load crosses onto that new slot…
|
||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'slot-new' });
|
||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'slot-new' });
|
||
// …and the source wagon stays itself, just empty.
|
||
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||
assignedWeightTons: 0,
|
||
status: 'PLANNED',
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
});
|
||
// The bug this replaced: the source slot must NOT be repinned to another
|
||
// physical wagon — that reorders the train instead of moving the load.
|
||
expect(slotRepo.update).not.toHaveBeenCalledWith(
|
||
'wA',
|
||
expect.objectContaining({ physicalWagonId: expect.anything() }),
|
||
);
|
||
});
|
||
|
||
it('reuses the existing slot when the target wagon is addressed by wagon id', async () => {
|
||
// wB is already pinned to physical wagon phys-B. Addressing that wagon
|
||
// directly must land in wB, not mint a second slot on the same wagon.
|
||
(slotB as Record<string, unknown>).physicalWagonId = 'phys-B';
|
||
wagonRepo.findOne.mockResolvedValue({
|
||
id: 'phys-B',
|
||
wagonTypeId: 'wt-1',
|
||
wagonNumber: 'WGN-B',
|
||
wagonType: containerType,
|
||
});
|
||
|
||
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-B' });
|
||
|
||
expect(slotRepo.save).not.toHaveBeenCalled();
|
||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
|
||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
|
||
});
|
||
|
||
it('rejects a bulk load onto a wagon whose type only supports containers', async () => {
|
||
allocsByWagon.wA = [
|
||
{ id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' },
|
||
];
|
||
allocsByWagon.wB = [];
|
||
|
||
await expect(
|
||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||
).rejects.toThrow(/cannot carry a bulk load/);
|
||
});
|
||
|
||
it('rejects when the incoming load exceeds the receiving wagon payload', async () => {
|
||
allocsByWagon.wA = [
|
||
{ id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' },
|
||
];
|
||
allocsByWagon.wB = [];
|
||
|
||
await expect(
|
||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||
).rejects.toThrow(/over its/);
|
||
});
|
||
});
|
||
|
||
describe('government booking protection', () => {
|
||
const scheduleId = 'sched-gov-1';
|
||
const govBooking = makeBooking('gov-1', 'BKG-GOV', 200, 10, '20FT', 10, undefined, undefined, undefined, {
|
||
isGovernment: true,
|
||
wagonsRequired: 10,
|
||
});
|
||
const commercial = makeBooking('bk-1', 'BKG-COM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
|
||
wagonsRequired: 5,
|
||
});
|
||
|
||
const scheduleGraph = {
|
||
id: scheduleId,
|
||
status: 'DRAFT',
|
||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
trainSetId: 'ts-1',
|
||
trainSet: {
|
||
id: 'ts-1',
|
||
locomotive,
|
||
wagons: [{ id: 'tsw-1' }, { id: 'tsw-2' }],
|
||
},
|
||
scheduleBookings: [{ bookingId: 'gov-1' }, { bookingId: 'bk-1' }],
|
||
};
|
||
|
||
it('unassignBooking rejects a government booking', async () => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
|
||
bookingsRepository.findById = jest.fn().mockResolvedValue(govBooking);
|
||
|
||
await expect(service.unassignBooking(scheduleId, 'gov-1')).rejects.toThrow(
|
||
/Government bookings cannot be removed/,
|
||
);
|
||
});
|
||
|
||
it('switchGovernmentBooking rejects a non-government incoming booking', async () => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
|
||
bookingsRepository.findByIdsForScheduling.mockResolvedValueOnce([commercial]);
|
||
|
||
await expect(
|
||
service.switchGovernmentBooking(scheduleId, 'bk-1', ['gov-1']),
|
||
).rejects.toThrow(/Only government bookings/);
|
||
});
|
||
|
||
it('switchGovernmentBooking rejects when the freed wagons are fewer than the government booking needs', async () => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === WagonBookingAllocation) {
|
||
return { find: jest.fn().mockResolvedValue([{ bookingId: 'bk-1' }]) };
|
||
}
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
});
|
||
bookingsRepository.findByIdsForScheduling.mockImplementation((ids: string[]) =>
|
||
Promise.resolve(
|
||
ids.map((id) => (id === 'gov-1' ? govBooking : commercial)),
|
||
),
|
||
);
|
||
jest
|
||
.spyOn(service as never as { resolveTrainLimitConfig: () => unknown }, 'resolveTrainLimitConfig')
|
||
.mockResolvedValue({} as never);
|
||
// Gov booking fits the plan (10 slots) but the switched-out booking only
|
||
// frees 5 wagons — the user-facing wagon rule must still reject it.
|
||
jest
|
||
.spyOn(
|
||
service as never as { validateBookingsForScheduling: () => unknown },
|
||
'validateBookingsForScheduling',
|
||
)
|
||
.mockResolvedValue({
|
||
valid: true,
|
||
violations: [],
|
||
warnings: [],
|
||
deferredBookings: [],
|
||
bookings: [govBooking],
|
||
wagonPlan: Array.from({ length: 10 }, (_, i) => ({
|
||
sequenceNo: i + 1,
|
||
allocations: [{ bookingId: 'gov-1' }],
|
||
})),
|
||
} as never);
|
||
|
||
await expect(
|
||
service.switchGovernmentBooking(scheduleId, 'gov-1', ['bk-1']),
|
||
).rejects.toThrow(/free only 5/);
|
||
});
|
||
});
|
||
|
||
describe('unassignBooking — MANUAL_ONLY status', () => {
|
||
const scheduleId = 'sched-rm-1';
|
||
const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
|
||
status: 'PAID',
|
||
wagonsRequired: 5,
|
||
});
|
||
|
||
const graph = {
|
||
id: scheduleId,
|
||
status: 'DRAFT',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
trainSetId: 'ts-rm',
|
||
trainSet: {
|
||
id: 'ts-rm',
|
||
locomotive,
|
||
trainId: null,
|
||
wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }],
|
||
},
|
||
scheduleBookings: [{ bookingId: 'bk-rm' }],
|
||
};
|
||
|
||
const txManager = {
|
||
getRepository: jest.fn(() => ({
|
||
find: jest.fn().mockResolvedValue([]),
|
||
findOne: jest.fn().mockResolvedValue(null),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
delete: jest.fn().mockResolvedValue(undefined),
|
||
save: jest.fn().mockResolvedValue(undefined),
|
||
create: jest.fn((x: unknown) => x),
|
||
})),
|
||
};
|
||
|
||
beforeEach(() => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph);
|
||
bookingsRepository.findById = jest.fn().mockResolvedValue(removed);
|
||
bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined);
|
||
dataSource.transaction.mockImplementation(
|
||
async (fn: (m: unknown) => Promise<void>) => fn(txManager),
|
||
);
|
||
jest
|
||
.spyOn(
|
||
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||
'getTrainScheduleById' as never,
|
||
)
|
||
.mockResolvedValue({ id: scheduleId } as never);
|
||
});
|
||
|
||
it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => {
|
||
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
|
||
|
||
expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith(
|
||
'bk-rm',
|
||
expect.objectContaining({
|
||
schedulingStatus: 'MANUAL_ONLY',
|
||
trainScheduleId: null,
|
||
wagonsRequired: null,
|
||
}),
|
||
expect.anything(),
|
||
);
|
||
expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith(
|
||
scheduleId,
|
||
'bk-rm',
|
||
expect.anything(),
|
||
);
|
||
expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith(
|
||
['alloc-rm-1'],
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => {
|
||
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
|
||
|
||
const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]);
|
||
expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('updateCheckpoint — leg time correction', () => {
|
||
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
|
||
const schedule = {
|
||
id: 'sch-track',
|
||
status: 'ARRIVED',
|
||
routeId: null,
|
||
originStationId: 'y0',
|
||
destinationStationId: 'y1',
|
||
actualDepartureAt: t(8),
|
||
};
|
||
const events = () => [
|
||
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
|
||
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
|
||
];
|
||
|
||
beforeEach(() => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
|
||
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
|
||
});
|
||
|
||
it('rejects a leg time earlier than the previous leg', async () => {
|
||
await expect(
|
||
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
|
||
).rejects.toThrow(/cannot be earlier than/);
|
||
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects a leg time later than the next leg', async () => {
|
||
await expect(
|
||
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
|
||
).rejects.toThrow(/cannot be later than/);
|
||
});
|
||
|
||
it('rejects a future time', async () => {
|
||
const future = new Date(Date.now() + 3_600_000).toISOString();
|
||
await expect(
|
||
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
|
||
).rejects.toThrow(/future/);
|
||
});
|
||
|
||
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
|
||
await service.updateCheckpoint('sch-track', 1, {
|
||
occurredAt: t(11).toISOString(),
|
||
note: 'late log',
|
||
});
|
||
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
|
||
occurredAt: t(11),
|
||
note: 'late log',
|
||
});
|
||
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
|
||
actualArrivalAt: t(11),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('effectiveWagonsRequired', () => {
|
||
const effective = (booking: unknown): number =>
|
||
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||
.effectiveWagonsRequired(booking);
|
||
|
||
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
|
||
// ceil(20/4) = 5 wagons.
|
||
const perItemBooking = (wagonsRequired: number | null) => ({
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: 20,
|
||
bulkTotalWeightTons: 100,
|
||
wagonsRequired,
|
||
cargoType: {
|
||
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
|
||
itemsPerWagonMap: { 'wt-nw5': 4 },
|
||
},
|
||
});
|
||
|
||
it('overrides a stale too-small stamp with the item-aware recompute', () => {
|
||
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
|
||
expect(effective(perItemBooking(1))).toBe(5);
|
||
});
|
||
|
||
it('keeps a stored stamp that is at least the recompute', () => {
|
||
expect(effective(perItemBooking(7))).toBe(7);
|
||
});
|
||
|
||
it('trusts the stamp when BULK cargo relations are not loaded', () => {
|
||
expect(
|
||
effective({
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: 20,
|
||
bulkTotalWeightTons: 100,
|
||
wagonsRequired: 5,
|
||
cargoType: null,
|
||
}),
|
||
).toBe(5);
|
||
});
|
||
});
|
||
|
||
describe('maintenanceReschedule — window reopens when it had already finished', () => {
|
||
const { TrainSchedule } = jest.requireActual(
|
||
'../../train-schedules/entities/train-schedule.entity',
|
||
);
|
||
|
||
const doneExportSchedule = (extra: Record<string, unknown> = {}) => ({
|
||
id: 'sch-done',
|
||
status: 'SCHEDULED',
|
||
direction: 'EXPORT',
|
||
windowPhase: 'DONE',
|
||
bookingWindowStatus: 'CLOSED',
|
||
scheduledDepartureDate: new Date('2027-06-20T05:00:00.000Z'),
|
||
scheduledArrivalDate: null,
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-destination',
|
||
scheduleBookings: [],
|
||
// Frozen rule snapshot: desk 8–17 EAT, 24h lead, close 120min before departure.
|
||
ruleWindowOpenHour: 8,
|
||
ruleWindowCloseHour: 17,
|
||
ruleExportBookingLeadHours: 24,
|
||
ruleExportCloseOffsetMinutes: 120,
|
||
...extra,
|
||
});
|
||
|
||
let scheduleUpdate: jest.Mock;
|
||
|
||
beforeEach(() => {
|
||
scheduleUpdate = jest.fn().mockResolvedValue({ affected: 1 });
|
||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||
if (entity === TrainSchedulingGlobalRules) {
|
||
return { find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
if (entity === TrainSchedule) {
|
||
return { update: scheduleUpdate, find: jest.fn().mockResolvedValue([]) };
|
||
}
|
||
return {
|
||
find: jest.fn().mockResolvedValue([]),
|
||
findOne: jest.fn().mockResolvedValue(null),
|
||
update: jest.fn(),
|
||
};
|
||
});
|
||
trainSchedulesRepository.findById.mockResolvedValue(null);
|
||
});
|
||
|
||
it('reopens a DONE export window against the new departure', async () => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||
doneExportSchedule(),
|
||
);
|
||
|
||
// New departure 12:00 EAT → window opens 24h earlier (12:00 EAT, inside
|
||
// the desk) and closes at departure − 120min = 10:00 EAT.
|
||
await service.maintenanceReschedule('sch-done', {
|
||
newDepartureDate: '2027-06-20T09:00:00.000Z',
|
||
} as never);
|
||
|
||
expect(scheduleUpdate).toHaveBeenCalledWith(
|
||
'sch-done',
|
||
expect.objectContaining({
|
||
scheduledDepartureDate: new Date('2027-06-20T09:00:00.000Z'),
|
||
windowPhase: 'PRE_WINDOW',
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowOpensAt: new Date('2027-06-19T09:00:00.000Z'),
|
||
windowClosesAt: new Date('2027-06-20T07:00:00.000Z'),
|
||
docReviewCompletedAt: null,
|
||
docReviewEndsAt: null,
|
||
paymentPhaseEndsAt: null,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('keeps a FULL train closed — nothing left to sell', async () => {
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||
doneExportSchedule({ bookingWindowStatus: 'FULL' }),
|
||
);
|
||
|
||
await service.maintenanceReschedule('sch-done', {
|
||
newDepartureDate: '2027-06-20T09:00:00.000Z',
|
||
} as never);
|
||
|
||
const written = scheduleUpdate.mock.calls[0][1];
|
||
expect(written.scheduledDepartureDate).toEqual(
|
||
new Date('2027-06-20T09:00:00.000Z'),
|
||
);
|
||
expect(written.windowPhase).toBeUndefined();
|
||
expect(written.windowOpensAt).toBeUndefined();
|
||
});
|
||
|
||
it('moves an OPEN export close to the new departure but keeps the open', async () => {
|
||
const opensAt = new Date('2027-06-19T03:00:00.000Z');
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||
doneExportSchedule({
|
||
windowPhase: 'OPEN',
|
||
bookingWindowStatus: 'OPEN',
|
||
windowOpensAt: opensAt,
|
||
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
|
||
}),
|
||
);
|
||
|
||
// Departure pushed 3 days later → close = new departure − 120min; the
|
||
// open customers already booked against stays untouched.
|
||
await service.maintenanceReschedule('sch-done', {
|
||
newDepartureDate: '2027-06-23T05:00:00.000Z',
|
||
} as never);
|
||
|
||
const written = scheduleUpdate.mock.calls[0][1];
|
||
expect(written.scheduledDepartureDate).toEqual(
|
||
new Date('2027-06-23T05:00:00.000Z'),
|
||
);
|
||
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
|
||
expect(written.windowOpensAt).toBeUndefined();
|
||
expect(written.windowPhase).toBeUndefined();
|
||
});
|
||
});
|
||
});
|