mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
feat(train-scheduling): implement container movement between wagons
- Added functionality to move containers between wagons in the train scheduling system. - Introduced API endpoint and service method to handle container movement. - Updated component to support drag-and-drop for rearranging containers. - Enhanced to allow moving containers to other wagons via a context menu. - Implemented UI feedback for container movement actions, including loading states and success/error notifications. - Updated relevant types and constants to accommodate new container movement logic. - Added tests for the rule engine to ensure proper handling of hazardous bookings.
This commit is contained in:
@@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireLeftoverExportDay — export day sweep', () => {
|
||||
const exportSchedule = {
|
||||
id: scheduleId,
|
||||
direction: 'EXPORT',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-dest',
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
windowPhase: 'DONE',
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
};
|
||||
let unacceptedSpy: jest.SpyInstance;
|
||||
let poolSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
unacceptedSpy = jest
|
||||
.spyOn(service, 'expireUnacceptedForRouteDay')
|
||||
.mockResolvedValue(undefined);
|
||||
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('ignores non-export schedules', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
...exportSchedule,
|
||||
direction: 'IMPORT',
|
||||
});
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers while another export train on the day can still take bookings', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers while a FULL train still has live pay windows', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'FULL',
|
||||
},
|
||||
]);
|
||||
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
||||
{
|
||||
paymentStatus: 'PENDING',
|
||||
status: 'AWAITING_PAYMENT',
|
||||
paymentDeadline: new Date(Date.now() + 60_000),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'FULL',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).toHaveBeenCalledWith({
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-dest',
|
||||
day: '2026-06-20',
|
||||
});
|
||||
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||
const importGeneral = {
|
||||
id: 'b1',
|
||||
@@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptIntercity — export pay window expires at window close', () => {
|
||||
const exportScheduleId = 'export-train';
|
||||
// Window closes in 30 minutes; the configured pay window is 60 minutes.
|
||||
const closesAt = new Date(Date.now() + 30 * 60_000);
|
||||
|
||||
const waiting = {
|
||||
id: 'ic-1',
|
||||
reference: 'IC-1',
|
||||
isGovernment: false,
|
||||
status: 'FULLY_EXECUTED',
|
||||
trainScheduleId: null,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: 10,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let scheduleRepo: { findOne: jest.Mock };
|
||||
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingRepo = dataSource.getRepository();
|
||||
bookingRepo.findOne.mockResolvedValue(waiting);
|
||||
scheduleRepo = { findOne: jest.fn() };
|
||||
// reserve() reads the target schedule to clamp export deadlines — route
|
||||
// TrainSchedule reads to their own repo, everything else stays as before.
|
||||
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
|
||||
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps the intercity pay deadline to the export window close', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
direction: 'EXPORT',
|
||||
windowClosesAt: closesAt,
|
||||
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
|
||||
});
|
||||
|
||||
await service.acceptIntercity(waiting, exportScheduleId);
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'ic-1',
|
||||
expect.objectContaining({
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: closesAt,
|
||||
}),
|
||||
);
|
||||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the plain payment window on import trains', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: 'import-train',
|
||||
direction: 'IMPORT',
|
||||
windowClosesAt: closesAt,
|
||||
});
|
||||
|
||||
await service.acceptIntercity(waiting, 'import-train');
|
||||
|
||||
const deadline = (
|
||||
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
|
||||
).paymentDeadline;
|
||||
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
|
||||
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
|
||||
});
|
||||
|
||||
it('rejects an accept after the export window closed — no pay window opens', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
direction: 'EXPORT',
|
||||
windowClosesAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.acceptIntercity(waiting, exportScheduleId),
|
||||
).rejects.toThrow(/window has closed/);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('expires an unpaid export ride-along at close and frees the train', async () => {
|
||||
const lapsed = {
|
||||
...(waiting as unknown as Record<string, unknown>),
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
trainScheduleId: exportScheduleId,
|
||||
paymentDeadline: new Date(Date.now() - 1_000),
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
priorityScore: 0,
|
||||
wagonsRequired: 1,
|
||||
} as unknown as Booking;
|
||||
bookingsRepository.findReservedForSchedule
|
||||
.mockResolvedValueOnce([lapsed])
|
||||
.mockResolvedValue([]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
|
||||
bookingRepo.findOne.mockResolvedValue(lapsed);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'DONE',
|
||||
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
});
|
||||
|
||||
await service.settleDueReservations(exportScheduleId);
|
||||
|
||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'ic-1',
|
||||
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingBatchService — wagonsFor', () => {
|
||||
|
||||
Reference in New Issue
Block a user