Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts
Marshal 3a1a08b1e1 add lashing surcharge for cargo types with hasLashing flag
add lashing surcharge for cargo types with hasLashing flag
2026-07-17 23:25:53 +00:00

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);
});
});
});