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

@@ -5,6 +5,7 @@ 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 { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -1081,4 +1082,143 @@ describe('TrainSchedulingService', () => {
expect(html).not.toContain('EMPTY');
});
});
describe('moveContainerItem — staff rearrange', () => {
const wagon1 = { id: 'w1', sequenceNo: 1, capacityTons: 61 };
const wagon2 = { id: 'w2', sequenceNo: 2, capacityTons: 61 };
const schedule = {
id: 'sched-1',
status: 'SCHEDULED',
trainSet: { wagons: [wagon1, wagon2] },
};
let sourceAlloc: Record<string, unknown>;
let item: Record<string, unknown>;
let itemRepo: { findOne: jest.Mock; update: jest.Mock; count: jest.Mock };
let allocRepo: {
find: jest.Mock;
findOne: jest.Mock;
create: jest.Mock;
save: jest.Mock;
update: jest.Mock;
delete: jest.Mock;
};
let wagon2Allocs: Array<Record<string, unknown>>;
beforeEach(() => {
sourceAlloc = {
id: 'alloc-1',
trainSetWagonId: 'w1',
bookingId: 'b1',
allocatedWeightTons: 20,
loadType: 'CONTAINER',
status: 'PLANNED',
containerItems: [],
};
item = {
id: 'item-1',
wagonBookingAllocationId: 'alloc-1',
positionOnWagon: 1,
grossWeightTons: 20,
containerType: { sizeFt: 20 },
allocation: sourceAlloc,
};
sourceAlloc.containerItems = [item];
wagon2Allocs = [];
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
itemRepo = {
findOne: jest.fn().mockResolvedValue(item),
update: jest.fn().mockResolvedValue(undefined),
count: jest.fn().mockResolvedValue(0),
};
allocRepo = {
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
Promise.resolve(where.trainSetWagonId === 'w1' ? [sourceAlloc] : wagon2Allocs),
),
findOne: jest.fn().mockImplementation(({ where }: { where: { id?: string } }) =>
Promise.resolve(where.id === 'alloc-1' ? { ...sourceAlloc } : null),
),
create: jest.fn((v: unknown) => v),
save: jest.fn().mockImplementation((v: Record<string, unknown>) =>
Promise.resolve({ ...v, id: 'alloc-new' }),
),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WagonAllocationContainerItem) return itemRepo;
if (entity === WagonBookingAllocation) return allocRepo;
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({
...schedule,
status: 'DISPATCHED',
});
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
).rejects.toThrow(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects a target wagon that has no TEU room left', async () => {
wagon2Allocs = [
{
id: 'alloc-2',
trainSetWagonId: 'w2',
bookingId: 'b2',
allocatedWeightTons: 25,
loadType: 'CONTAINER',
containerItems: [{ id: 'item-40', containerType: { sizeFt: 40 } }],
},
];
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
).rejects.toThrow(/no room/);
});
it('rejects a bulk-loaded target wagon', async () => {
wagon2Allocs = [
{
id: 'alloc-2',
trainSetWagonId: 'w2',
bookingId: 'b2',
allocatedWeightTons: 40,
loadType: 'BULK',
containerItems: [],
},
];
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
).rejects.toThrow(/bulk/);
});
it('moves a container to an empty wagon and re-homes its allocation', async () => {
await service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' });
// A new allocation for the booking was created on the target wagon…
expect(allocRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ trainSetWagonId: 'w2', bookingId: 'b1' }),
);
// …the container item now hangs off it…
expect(itemRepo.update).toHaveBeenCalledWith('item-1', {
wagonBookingAllocationId: 'alloc-new',
});
// …and the emptied source allocation was deleted, not left at 0 items.
expect(allocRepo.delete).toHaveBeenCalledWith('alloc-1');
});
});
});