mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
refactor(train-scheduling): rename and restructure container movement logic
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class MoveContainerItemDto {
|
||||
@IsUUID()
|
||||
targetTrainSetWagonId!: string;
|
||||
|
||||
/**
|
||||
* Swap with this container on the target wagon instead of requiring free
|
||||
* space there. Same-wagon swaps exchange the two slot positions.
|
||||
*/
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
swapWithItemId?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class MoveWagonLoadDto {
|
||||
/**
|
||||
* Where the source wagon's whole load goes: a train-set wagon slot (empty →
|
||||
* move, loaded → swap the two loads) or an empty consist-only physical wagon
|
||||
* of the built train (→ the slot repins onto it).
|
||||
*/
|
||||
@IsUUID()
|
||||
targetWagonId!: string;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||
import { MoveContainerItemDto } from "./dto/move-container-item.dto";
|
||||
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
|
||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||
@@ -375,17 +375,18 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/container-items/:itemId/move")
|
||||
@Post("schedules/:id/wagons/:wagonId/move-load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: "Move a container to another wagon (optionally swapping two containers)",
|
||||
summary:
|
||||
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
|
||||
})
|
||||
moveContainerItem(
|
||||
moveWagonLoad(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("itemId", ParseUUIDPipe) itemId: string,
|
||||
@Body() dto: MoveContainerItemDto,
|
||||
@Param("wagonId", ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: MoveWagonLoadDto,
|
||||
) {
|
||||
return this.trainSchedulingService.moveContainerItem(id, itemId, dto);
|
||||
return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/unassigned-bookings")
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
TrainScheduleFreightType,
|
||||
} from './dto/list-train-schedules-query.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { MoveContainerItemDto } from './dto/move-container-item.dto';
|
||||
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
@@ -7336,226 +7336,165 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff rearrange: move one container to another wagon of the same train, or
|
||||
* swap two containers (cross-wagon, or same-wagon to exchange slot positions).
|
||||
* Capacity is re-validated here — one wagon holds 2 TEU (one 40ft or two
|
||||
* 20ft) and the wagon's rated payload is never exceeded — so a drag on the
|
||||
* consist can't silently overload a wagon. Allocation rows follow the items:
|
||||
* the booking gets an allocation on the target wagon (created if missing),
|
||||
* weights shift with the container, and an allocation left with no items is
|
||||
* deleted.
|
||||
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
|
||||
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
|
||||
* Whole-load moves keep every packing rule intact by construction (a valid
|
||||
* load stays valid on any wagon whose type supports it), which is what lets
|
||||
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
|
||||
* swap with containers.
|
||||
*
|
||||
* Three shapes, picked from the target:
|
||||
* - target is an empty consist-only wagon (coupled on the built train, no
|
||||
* slot row): REPIN — the source slot simply points at that physical wagon
|
||||
* (type/capacity/length follow), and the wagon it left shows as empty.
|
||||
* - target is an empty slot: allocations repoint to it and the load-coupled
|
||||
* slot fields (assigned weight, status, board/alight leg) move across.
|
||||
* - target is a loaded slot: the two loads swap wagons the same way.
|
||||
*
|
||||
* Validated per direction: the receiving wagon's type must support the
|
||||
* incoming load type, and the incoming cargo must fit its rated payload.
|
||||
*/
|
||||
async moveContainerItem(
|
||||
async moveWagonLoad(
|
||||
scheduleId: string,
|
||||
itemId: string,
|
||||
dto: MoveContainerItemDto,
|
||||
sourceWagonId: string,
|
||||
dto: MoveWagonLoadDto,
|
||||
): Promise<any> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
|
||||
throw new BadRequestException('Cannot rearrange containers on a dispatched train');
|
||||
throw new BadRequestException('Cannot rearrange loads on a dispatched train');
|
||||
}
|
||||
|
||||
const wagonById = new Map((schedule.trainSet?.wagons ?? []).map((w) => [w.id, w]));
|
||||
const itemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
|
||||
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||
|
||||
const item = await itemRepo.findOne({
|
||||
where: { id: itemId },
|
||||
relations: { allocation: true, containerType: true },
|
||||
});
|
||||
const sourceWagon = item?.allocation
|
||||
? wagonById.get(item.allocation.trainSetWagonId)
|
||||
: undefined;
|
||||
if (!item?.allocation || !sourceWagon) {
|
||||
throw new NotFoundException(`Container item ${itemId} not found on this schedule`);
|
||||
}
|
||||
const targetWagon = wagonById.get(dto.targetTrainSetWagonId);
|
||||
if (!targetWagon) {
|
||||
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||
}
|
||||
|
||||
const loadAllocations = (trainSetWagonId: string) =>
|
||||
allocRepo.find({
|
||||
where: { trainSetWagonId },
|
||||
relations: { containerItems: { containerType: true } },
|
||||
});
|
||||
const [sourceAllocs, targetAllocs] = await Promise.all([
|
||||
loadAllocations(sourceWagon.id),
|
||||
loadAllocations(targetWagon.id),
|
||||
]);
|
||||
if (
|
||||
targetAllocs.some((a) => (a.loadType ?? '').toUpperCase().includes('BULK'))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Wagon #${targetWagon.sequenceNo} carries a bulk load — containers cannot ride it`,
|
||||
);
|
||||
}
|
||||
|
||||
const swapItem = dto.swapWithItemId
|
||||
? targetAllocs
|
||||
.flatMap((a) => a.containerItems ?? [])
|
||||
.find((it) => it.id === dto.swapWithItemId)
|
||||
: undefined;
|
||||
if (dto.swapWithItemId && !swapItem) {
|
||||
throw new BadRequestException('The container to swap with is not on the target wagon');
|
||||
}
|
||||
if (swapItem?.id === item.id) {
|
||||
throw new BadRequestException('Cannot swap a container with itself');
|
||||
}
|
||||
if (sourceWagon.id === targetWagon.id && !swapItem) {
|
||||
if (sourceWagonId === dto.targetWagonId) {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
// TEU per container: 40ft fills a wagon (2), 20ft takes half (1). One
|
||||
// wagon never exceeds 2 TEU — the same rule the auto-allocation packs by.
|
||||
const MAX_TEU_PER_WAGON = 2;
|
||||
const teuOf = (it: { containerType?: { sizeFt?: number | null } | null }) =>
|
||||
(it.containerType?.sizeFt ?? 20) >= 40 ? 2 : 1;
|
||||
const itemsOf = (allocs: WagonBookingAllocation[]) =>
|
||||
allocs.flatMap((a) => a.containerItems ?? []);
|
||||
// Weight a container carries into the move: its own gross when recorded,
|
||||
// otherwise an even share of its allocation's weight.
|
||||
const weightOf = (
|
||||
it: WagonAllocationContainerItem,
|
||||
alloc: WagonBookingAllocation,
|
||||
siblings: number,
|
||||
) =>
|
||||
Number(it.grossWeightTons) ||
|
||||
Number(alloc.allocatedWeightTons) / Math.max(1, siblings);
|
||||
|
||||
const sourceAlloc = sourceAllocs.find((a) => a.id === item.wagonBookingAllocationId);
|
||||
if (!sourceAlloc) {
|
||||
throw new NotFoundException(`Container item ${itemId} not found on this schedule`);
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const source = slots.find((w) => w.id === sourceWagonId);
|
||||
if (!source) {
|
||||
throw new NotFoundException('Source wagon is not part of this schedule');
|
||||
}
|
||||
const itemWeight = weightOf(item, sourceAlloc, (sourceAlloc.containerItems ?? []).length);
|
||||
const swapAlloc = swapItem
|
||||
? targetAllocs.find((a) => a.id === swapItem.wagonBookingAllocationId)
|
||||
: undefined;
|
||||
const swapWeight =
|
||||
swapItem && swapAlloc
|
||||
? weightOf(swapItem, swapAlloc, (swapAlloc.containerItems ?? []).length)
|
||||
: 0;
|
||||
|
||||
if (sourceWagon.id !== targetWagon.id) {
|
||||
const targetTeu = itemsOf(targetAllocs)
|
||||
.filter((it) => it.id !== swapItem?.id)
|
||||
.reduce((sum, it) => sum + teuOf(it), 0);
|
||||
if (targetTeu + teuOf(item) > MAX_TEU_PER_WAGON) {
|
||||
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||
const loadAllocations = (trainSetWagonId: string) =>
|
||||
allocRepo.find({ where: { trainSetWagonId } });
|
||||
const sourceAllocs = await loadAllocations(source.id);
|
||||
if (!sourceAllocs.length) {
|
||||
throw new BadRequestException('Source wagon has no load to move');
|
||||
}
|
||||
|
||||
// Target: a slot of this train set, or an empty consist-only wagon of the
|
||||
// built train (physical wagon with no slot row yet).
|
||||
const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
||||
const consistWagon = targetSlot
|
||||
? null
|
||||
: schedule.trainSet?.trainId
|
||||
? await this.dataSource.getRepository(Wagon).findOne({
|
||||
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
if (!targetSlot && !consistWagon) {
|
||||
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||
}
|
||||
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
||||
|
||||
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||||
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||||
];
|
||||
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
|
||||
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
const checkReceives = (
|
||||
allocs: WagonBookingAllocation[],
|
||||
label: string,
|
||||
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
||||
capacityTons: number,
|
||||
) => {
|
||||
const incoming = loadTypesOf(allocs);
|
||||
// Unknown type or no declared support list → staff decides; don't block.
|
||||
if (wagonType) {
|
||||
const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
|
||||
for (const loadType of incoming) {
|
||||
const ok =
|
||||
supported.includes(loadType) ||
|
||||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||||
supported.length === 0;
|
||||
if (!ok) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cargo = cargoOf(allocs);
|
||||
if (capacityTons > 0 && cargo > capacityTons + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Wagon #${targetWagon.sequenceNo} has no room — a wagon holds one 40ft or two 20ft containers`,
|
||||
`Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
|
||||
);
|
||||
}
|
||||
if (swapItem) {
|
||||
const sourceTeu = itemsOf(sourceAllocs)
|
||||
.filter((it) => it.id !== item.id)
|
||||
.reduce((sum, it) => sum + teuOf(it), 0);
|
||||
if (sourceTeu + teuOf(swapItem) > MAX_TEU_PER_WAGON) {
|
||||
throw new BadRequestException(
|
||||
`Wagon #${sourceWagon.sequenceNo} has no room for the swapped container — a wagon holds one 40ft or two 20ft containers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cargoOn = (allocs: WagonBookingAllocation[]) =>
|
||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||
const checkPayload = (
|
||||
wagon: { sequenceNo: number; capacityTons?: number | null },
|
||||
cargoAfter: number,
|
||||
) => {
|
||||
const capacity = Number(wagon.capacityTons ?? 0);
|
||||
if (capacity > 0 && cargoAfter > capacity + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Wagon #${wagon.sequenceNo} would carry ${roundTons(cargoAfter)}T — over its ${capacity}T payload`,
|
||||
);
|
||||
}
|
||||
};
|
||||
checkPayload(targetWagon, cargoOn(targetAllocs) - swapWeight + itemWeight);
|
||||
if (swapItem) {
|
||||
checkPayload(sourceWagon, cargoOn(sourceAllocs) - itemWeight + swapWeight);
|
||||
}
|
||||
// What the target must be able to receive…
|
||||
checkReceives(
|
||||
sourceAllocs,
|
||||
wagonLabel(targetSlot, consistWagon),
|
||||
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
|
||||
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
|
||||
);
|
||||
// …and, on a swap, what comes back to the source.
|
||||
if (targetAllocs.length) {
|
||||
checkReceives(
|
||||
targetAllocs,
|
||||
`#${source.sequenceNo}`,
|
||||
source.wagonType,
|
||||
Number(source.capacityTons),
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const items = manager.getRepository(WagonAllocationContainerItem);
|
||||
const slotRepo = manager.getRepository(TrainSetWagon);
|
||||
const allocs = manager.getRepository(WagonBookingAllocation);
|
||||
|
||||
// Same-wagon swap: the containers only trade slot positions.
|
||||
if (sourceWagon.id === targetWagon.id && swapItem) {
|
||||
const a = item.positionOnWagon ?? null;
|
||||
const b = swapItem.positionOnWagon ?? null;
|
||||
await items.update(item.id, { positionOnWagon: b });
|
||||
await items.update(swapItem.id, { positionOnWagon: a });
|
||||
// Empty consist wagon: repin the loaded slot onto that physical wagon.
|
||||
// Allocations and load fields stay put; only the wagon identity changes.
|
||||
if (consistWagon) {
|
||||
await slotRepo.update(source.id, {
|
||||
physicalWagonId: consistWagon.id,
|
||||
wagonTypeId: consistWagon.wagonTypeId,
|
||||
capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)),
|
||||
lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const moveOne = async (
|
||||
moving: WagonAllocationContainerItem,
|
||||
toWagonId: string,
|
||||
weight: number,
|
||||
) => {
|
||||
// Re-read the source allocation — the other leg of a swap may have
|
||||
// already shifted weight on it within this transaction.
|
||||
const from = await allocs.findOne({
|
||||
where: { id: moving.wagonBookingAllocationId },
|
||||
});
|
||||
if (!from) return;
|
||||
let to = await allocs.findOne({
|
||||
where: { trainSetWagonId: toWagonId, bookingId: from.bookingId },
|
||||
});
|
||||
if (!to) {
|
||||
to = await allocs.save(
|
||||
allocs.create({
|
||||
trainSetWagonId: toWagonId,
|
||||
bookingId: from.bookingId,
|
||||
allocatedWeightTons: 0,
|
||||
loadType: from.loadType ?? 'CONTAINER',
|
||||
status: from.status ?? 'PLANNED',
|
||||
}),
|
||||
);
|
||||
}
|
||||
await items.update(moving.id, { wagonBookingAllocationId: to.id });
|
||||
await allocs.update(to.id, {
|
||||
allocatedWeightTons: roundTons(Number(to.allocatedWeightTons) + weight),
|
||||
});
|
||||
const remaining = await items.count({
|
||||
where: { wagonBookingAllocationId: from.id },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
await allocs.delete(from.id);
|
||||
} else {
|
||||
await allocs.update(from.id, {
|
||||
allocatedWeightTons: roundTons(
|
||||
Math.max(0, Number(from.allocatedWeightTons) - weight),
|
||||
),
|
||||
});
|
||||
}
|
||||
const target = targetSlot as TrainSetWagon;
|
||||
// Load-coupled slot fields travel with the load; wagon identity stays.
|
||||
const loadFieldsOf = (slot: TrainSetWagon) => ({
|
||||
assignedWeightTons: slot.assignedWeightTons,
|
||||
status: slot.status,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
alightYardId: slot.alightYardId ?? null,
|
||||
});
|
||||
const emptyLoadFields = {
|
||||
assignedWeightTons: 0,
|
||||
status: 'PLANNED',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
};
|
||||
const sourceLoadFields = loadFieldsOf(source);
|
||||
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
|
||||
|
||||
await moveOne(item, targetWagon.id, itemWeight);
|
||||
if (swapItem) {
|
||||
await moveOne(swapItem, sourceWagon.id, swapWeight);
|
||||
for (const alloc of sourceAllocs) {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: target.id });
|
||||
}
|
||||
|
||||
// Keep slot positions dense (1..n) on both touched wagons.
|
||||
const renumber = async (trainSetWagonId: string) => {
|
||||
const wagonAllocs = await allocs.find({
|
||||
where: { trainSetWagonId },
|
||||
relations: { containerItems: true },
|
||||
});
|
||||
const wagonItems = wagonAllocs
|
||||
.flatMap((a) => a.containerItems ?? [])
|
||||
.sort((x, y) => (x.positionOnWagon ?? 99) - (y.positionOnWagon ?? 99));
|
||||
for (let i = 0; i < wagonItems.length; i += 1) {
|
||||
if (wagonItems[i].positionOnWagon !== i + 1) {
|
||||
await items.update(wagonItems[i].id, { positionOnWagon: i + 1 });
|
||||
}
|
||||
}
|
||||
};
|
||||
await renumber(sourceWagon.id);
|
||||
await renumber(targetWagon.id);
|
||||
for (const alloc of targetAllocs) {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: source.id });
|
||||
}
|
||||
await slotRepo.update(target.id, sourceLoadFields);
|
||||
await slotRepo.update(source.id, targetLoadFields);
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
|
||||
Reference in New Issue
Block a user