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

@@ -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);