mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
246 lines
7.8 KiB
TypeScript
246 lines
7.8 KiB
TypeScript
import { ConflictException } from '@nestjs/common';
|
|
|
|
import { BookingBatchService } from './booking-batch.service';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
|
|
/**
|
|
* Export whole-booking single-train gate: an export booking never splits — it
|
|
* rides one train whole or is rejected. The report must try every fillable
|
|
* train on the day (first full → use the second), and when none fits, say how
|
|
* much space is still bookable so the customer knows what he CAN book.
|
|
*/
|
|
describe('BookingBatchService — exportSpaceReport (whole-booking, single train)', () => {
|
|
const DAY = new Date('2026-07-20T10:00:00Z');
|
|
|
|
const schedule = (id: string) => ({
|
|
id,
|
|
status: 'SCHEDULED',
|
|
direction: 'EXPORT',
|
|
scheduledDepartureDate: DAY,
|
|
bookingWindowStatus: 'OPEN',
|
|
windowPhase: null, // legacy gate: OPEN alone makes it fillable
|
|
});
|
|
|
|
const fullGraph = (id: string) => ({
|
|
id,
|
|
originStationId: 'yard-a',
|
|
destinationStationId: 'yard-b',
|
|
routeId: null, // legacy two-stop pseudo-route — no milestone query
|
|
scheduleBookings: [],
|
|
trainSet: {
|
|
locomotive: {
|
|
maxPullWeightTons: 500,
|
|
maxTrainLengthMeters: 140,
|
|
overageToleranceTons: 0,
|
|
overageToleranceMeters: 0,
|
|
},
|
|
},
|
|
});
|
|
|
|
const exportBooking = (cargoTons: number) =>
|
|
({
|
|
id: 'bk-exp',
|
|
freightType: 'BULK',
|
|
tradeDirection: 'EXPORT',
|
|
scheduledDate: DAY,
|
|
originYardId: 'yard-a',
|
|
destinationYardId: 'yard-b',
|
|
cargoTotalWeightVgm: cargoTons,
|
|
bookingContainers: [],
|
|
}) as unknown as Booking;
|
|
|
|
// A reserved bulk booking heavy enough to exhaust the 500t pull budget.
|
|
const heavyReserved = {
|
|
id: 'bk-heavy',
|
|
freightType: 'BULK',
|
|
originYardId: 'yard-a',
|
|
destinationYardId: 'yard-b',
|
|
cargoTotalWeightVgm: 476,
|
|
bookingContainers: [],
|
|
} as unknown as Booking;
|
|
|
|
let service: BookingBatchService;
|
|
let trainSchedulesRepository: {
|
|
findAll: jest.Mock;
|
|
findByIdWithFullGraph: jest.Mock;
|
|
findById: jest.Mock;
|
|
};
|
|
let bookingsRepository: { findReservedForSchedule: jest.Mock };
|
|
|
|
beforeEach(() => {
|
|
trainSchedulesRepository = {
|
|
findAll: jest.fn().mockResolvedValue([]),
|
|
findByIdWithFullGraph: jest
|
|
.fn()
|
|
.mockImplementation(async (id: string) => fullGraph(id)),
|
|
findById: jest.fn(),
|
|
};
|
|
bookingsRepository = { findReservedForSchedule: jest.fn().mockResolvedValue([]) };
|
|
|
|
// WagonType.find() → [] so representative default dims apply (bulk 60t
|
|
// payload / 23.4t tare / 14m); RouteMilestone is never queried (routeId null).
|
|
const genericRepo = { find: jest.fn().mockResolvedValue([]) };
|
|
const dataSource = { getRepository: jest.fn().mockReturnValue(genericRepo) };
|
|
|
|
service = new BookingBatchService(
|
|
dataSource as never,
|
|
bookingsRepository as never,
|
|
trainSchedulesRepository as never,
|
|
{} as never, // trainScheduleBookingsRepository
|
|
{} as never, // notifier
|
|
{} as never, // scheduler
|
|
{} as never, // trainSchedulingService
|
|
{} as never, // billing
|
|
{} as never, // bookingWindowGateway
|
|
{} as never, // pricingService
|
|
);
|
|
});
|
|
|
|
it('uses the other train when the first one is full', async () => {
|
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
|
schedule('train-1'),
|
|
schedule('train-2'),
|
|
]);
|
|
bookingsRepository.findReservedForSchedule.mockImplementation(
|
|
async (id: string) => (id === 'train-1' ? [heavyReserved] : []),
|
|
);
|
|
|
|
const report = await service.exportSpaceReport(exportBooking(60));
|
|
|
|
expect(report.scheduleId).toBe('train-2');
|
|
});
|
|
|
|
it('rejects a booking no single train fits and reports the bookable space', async () => {
|
|
trainSchedulesRepository.findAll.mockResolvedValue([schedule('train-1')]);
|
|
|
|
const report = await service.exportSpaceReport(exportBooking(900));
|
|
|
|
expect(report.scheduleId).toBeNull();
|
|
expect(report.bestAvailable).not.toBeNull();
|
|
expect(report.bestAvailable!.cargoTons).toBeGreaterThan(0);
|
|
expect(report.bestAvailable!.cargoTons).toBeLessThan(900);
|
|
expect(report.fullMessage).toMatch(/largest remaining space is about .* tons/);
|
|
expect(report.fullMessage).toMatch(/single train whole/);
|
|
|
|
await expect(service.pickExportSchedule(exportBooking(900))).rejects.toThrow(
|
|
ConflictException,
|
|
);
|
|
});
|
|
|
|
it('says no train is accepting bookings when the day has none', async () => {
|
|
trainSchedulesRepository.findAll.mockResolvedValue([]);
|
|
|
|
const report = await service.exportSpaceReport(exportBooking(60));
|
|
|
|
expect(report.scheduleId).toBeNull();
|
|
expect(report.fullMessage).toBe(
|
|
'No export train is accepting bookings for this day',
|
|
);
|
|
});
|
|
|
|
describe('dayImportAvailability (advisory, summed across the day)', () => {
|
|
const DAY_STR = '2026-07-20';
|
|
|
|
const importSchedule = (id: string, over: Record<string, unknown> = {}) => ({
|
|
id,
|
|
status: 'SCHEDULED',
|
|
direction: 'IMPORT',
|
|
scheduledDepartureDate: DAY,
|
|
bookingWindowStatus: 'OPEN',
|
|
windowPhase: 'OPEN', // still OPEN — the advisory ignores the fill phase
|
|
...over,
|
|
});
|
|
|
|
// A bulk booking small enough to fit; freeWagons is what matters, not `fits`.
|
|
const importBooking = (cargoTons: number) =>
|
|
({
|
|
id: 'bk-imp',
|
|
freightType: 'BULK',
|
|
tradeDirection: 'IMPORT',
|
|
originYardId: 'yard-a',
|
|
destinationYardId: 'yard-b',
|
|
cargoTotalWeightVgm: cargoTons,
|
|
bookingContainers: [],
|
|
}) as unknown as Booking;
|
|
|
|
it('sums free wagons across every import train on the day', async () => {
|
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
|
importSchedule('train-1'),
|
|
importSchedule('train-2'),
|
|
]);
|
|
|
|
const one = await service.dayImportAvailability(
|
|
importBooking(60),
|
|
DAY_STR,
|
|
);
|
|
// Re-run with a single train to prove two trains sum to double one train.
|
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
|
importSchedule('train-1'),
|
|
]);
|
|
const solo = await service.dayImportAvailability(
|
|
importBooking(60),
|
|
DAY_STR,
|
|
);
|
|
|
|
expect(solo.freeWagons).toBeGreaterThan(0);
|
|
expect(one.freeWagons).toBe(solo.freeWagons * 2);
|
|
expect(one.trainsForDay).toBe(true);
|
|
});
|
|
|
|
it('ignores EXPORT trains — they are not part of the import pool', async () => {
|
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
|
importSchedule('train-1'),
|
|
{ ...importSchedule('train-2'), direction: 'EXPORT' },
|
|
]);
|
|
|
|
const both = await service.dayImportAvailability(
|
|
importBooking(60),
|
|
DAY_STR,
|
|
);
|
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
|
importSchedule('train-1'),
|
|
]);
|
|
const solo = await service.dayImportAvailability(
|
|
importBooking(60),
|
|
DAY_STR,
|
|
);
|
|
|
|
expect(both.freeWagons).toBe(solo.freeWagons);
|
|
});
|
|
|
|
it('ignores FULL trains', async () => {
|
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
|
importSchedule('train-1', { bookingWindowStatus: 'FULL' }),
|
|
]);
|
|
|
|
const report = await service.dayImportAvailability(
|
|
importBooking(60),
|
|
DAY_STR,
|
|
);
|
|
|
|
expect(report.freeWagons).toBe(0);
|
|
expect(report.trainsForDay).toBe(false);
|
|
});
|
|
|
|
it('nets out capacity already held by reserved bookings', async () => {
|
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
|
importSchedule('train-1'),
|
|
]);
|
|
const empty = await service.dayImportAvailability(
|
|
importBooking(60),
|
|
DAY_STR,
|
|
);
|
|
|
|
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
|
heavyReserved,
|
|
]);
|
|
const withHold = await service.dayImportAvailability(
|
|
importBooking(60),
|
|
DAY_STR,
|
|
);
|
|
|
|
expect(withHold.freeWagons).toBeLessThan(empty.freeWagons);
|
|
});
|
|
});
|
|
});
|