refactor(train-scheduling): rename and restructure container movement logic

This commit is contained in:
Marshal
2026-07-21 23:49:10 +00:00
parent d25612c2f0
commit ed3c8307bb
13 changed files with 470 additions and 523 deletions

View File

@@ -5,7 +5,6 @@ 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';
@@ -1083,72 +1082,75 @@ describe('TrainSchedulingService', () => {
});
});
describe('moveContainerItem — staff rearrange', () => {
const wagon1 = { id: 'w1', sequenceNo: 1, capacityTons: 61 };
const wagon2 = { id: 'w2', sequenceNo: 2, capacityTons: 61 };
const schedule = {
describe('moveWagonLoad — staff rearrange', () => {
const containerType = {
code: 'NX70',
supportedLoadTypes: ['CONTAINER'],
supportsContainer: true,
};
let slotA: Record<string, unknown>;
let slotB: Record<string, unknown>;
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
let allocRepo: { find: jest.Mock; update: jest.Mock };
let slotRepo: { update: jest.Mock };
let wagonRepo: { findOne: jest.Mock };
const makeSchedule = (over: Record<string, unknown> = {}) => ({
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>>;
trainSetId: 'ts-1',
trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
...over,
});
beforeEach(() => {
sourceAlloc = {
id: 'alloc-1',
trainSetWagonId: 'w1',
bookingId: 'b1',
allocatedWeightTons: 20,
loadType: 'CONTAINER',
status: 'PLANNED',
containerItems: [],
slotA = {
id: 'wA',
sequenceNo: 1,
capacityTons: 61,
lengthMeters: 14,
assignedWeightTons: 40,
status: 'RESERVED',
boardYardId: 'yard-1',
alightYardId: null,
wagonType: containerType,
};
item = {
id: 'item-1',
wagonBookingAllocationId: 'alloc-1',
positionOnWagon: 1,
grossWeightTons: 20,
containerType: { sizeFt: 20 },
allocation: sourceAlloc,
slotB = {
id: 'wB',
sequenceNo: 2,
capacityTons: 61,
lengthMeters: 14,
assignedWeightTons: 25,
status: 'RESERVED',
boardYardId: null,
alightYardId: null,
wagonType: containerType,
};
allocsByWagon = {
// 20ft pair (two allocations sharing wagon A) — must travel together.
wA: [
{ id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' },
{ id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' },
],
// one 40ft on wagon B.
wB: [
{ id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' },
],
};
sourceAlloc.containerItems = [item];
wagon2Allocs = [];
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
itemRepo = {
findOne: jest.fn().mockResolvedValue(item),
update: jest.fn().mockResolvedValue(undefined),
count: jest.fn().mockResolvedValue(0),
};
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
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' }),
Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
slotRepo = { update: jest.fn().mockResolvedValue(undefined) };
wagonRepo = { findOne: jest.fn().mockResolvedValue(null) };
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WagonAllocationContainerItem) return itemRepo;
if (entity === WagonBookingAllocation) return allocRepo;
if (entity === TrainSetWagon) return slotRepo;
if (entity === Wagon) return wagonRepo;
return { find: jest.fn().mockResolvedValue([]) };
});
dataSource.transaction.mockImplementation(
@@ -1164,61 +1166,87 @@ describe('TrainSchedulingService', () => {
});
it('rejects moves on a dispatched train', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
...schedule,
status: 'DISPATCHED',
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
makeSchedule({ status: 'DISPATCHED' }),
);
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
).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 } }],
},
];
it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
).rejects.toThrow(/no room/);
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
).rejects.toThrow(/not part of this schedule/);
});
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('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });
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',
// The 20ft pair moved together onto wagon B…
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
// …and the 40ft came back to wagon A.
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
// Load-coupled slot fields follow their loads.
expect(slotRepo.update).toHaveBeenCalledWith('wB', {
assignedWeightTons: 40,
status: 'RESERVED',
boardYardId: 'yard-1',
alightYardId: null,
});
// …and the emptied source allocation was deleted, not left at 0 items.
expect(allocRepo.delete).toHaveBeenCalledWith('alloc-1');
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
assignedWeightTons: 25,
status: 'RESERVED',
boardYardId: null,
alightYardId: null,
});
});
it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => {
wagonRepo.findOne.mockResolvedValue({
id: 'phys-9',
wagonTypeId: 'wt-1',
wagonNumber: 'WGN-9',
wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 },
});
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' });
expect(wagonRepo.findOne).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }),
);
// Repin: wagon identity moves onto the slot; allocations stay put.
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
physicalWagonId: 'phys-9',
wagonTypeId: 'wt-1',
capacityTons: 70,
lengthMeters: 14,
});
expect(allocRepo.update).not.toHaveBeenCalled();
});
it('rejects a bulk load onto a wagon whose type only supports containers', async () => {
allocsByWagon.wA = [
{ id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' },
];
allocsByWagon.wB = [];
await expect(
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
).rejects.toThrow(/cannot carry a bulk load/);
});
it('rejects when the incoming load exceeds the receiving wagon payload', async () => {
allocsByWagon.wA = [
{ id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' },
];
allocsByWagon.wB = [];
await expect(
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
).rejects.toThrow(/over its/);
});
});
});