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:
Marshal
2026-07-21 23:02:06 +00:00
parent 00a81fda15
commit 835c9e111c
35 changed files with 1896 additions and 154 deletions

View File

@@ -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', () => {