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

@@ -77,6 +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 { 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';
@@ -122,7 +123,7 @@ import {
sumWagonsRequired,
type TrainLimitConfig,
validateContainerPlacements,
validateMixedTrainLimits,
validateMixedTrainLimitsPerEdge,
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
@@ -1538,8 +1539,21 @@ export class TrainSchedulingService {
}
}
// The rebuild below deletes EVERY schedule↔booking link row and recreates
// only what makes the new plan. Ride-along (intercity) bookings are linked
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
// workspace's picked ids, so planning from dto.bookingIds alone silently
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
// every list. Every (re)assignment therefore re-plans the WHOLE train:
// the requested ids plus everything currently linked.
const linkedRows =
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
const allBookingIds = [
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
];
const previewDto = {
bookingIds: dto.bookingIds,
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
@@ -1562,8 +1576,13 @@ export class TrainSchedulingService {
// preview the wagon plan first, then lay containers into the plan's slots.
// Without this the placement validator rejects container bookings outright
// ("Container placements are required for container bookings").
// Callers hand-pick placements only for the bookings they know about; the
// union above may have folded in linked ride-alongs those placements never
// covered. Auto-fill whatever units are missing (all of them when no
// placements were sent at all) so the placement validator doesn't reject
// container bookings the caller couldn't have placed.
let containerPlacements = dto.containerPlacements;
if (!containerPlacements?.length) {
{
const preview = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -1578,18 +1597,28 @@ export class TrainSchedulingService {
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
const providedKeys = new Set(
(containerPlacements ?? []).map(
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
),
);
const unplacedUnits = units.filter(
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(unplacedUnits, slots);
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
}
containerPlacements = [...(containerPlacements ?? []), ...generated];
}
containerPlacements = generated;
}
}
@@ -1632,8 +1661,10 @@ export class TrainSchedulingService {
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
// A stock shortage is a physical impossibility, so forceAssign cannot
// override it either.
// Linked ride-alongs count as requested too: silently dropping one here is
// exactly the delete-and-recreate orphan this method must never produce.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id));
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
if (droppedRequested.length) {
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
@@ -3852,25 +3883,24 @@ export class TrainSchedulingService {
);
}
// Corridor-aware: a booking belongs on this train when its origin and
// destination lie on the schedule's stop list in order — sub-corridor
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
// stop list is also what makes the wagon plan leg-aware below.
let stops = [dto.originStationId, dto.destinationStationId];
if (targetScheduleId) {
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
if (target) stops = await this.stopYardsForSchedule(target);
}
if (
await (async () => {
// Corridor-aware: a booking belongs on this train when its origin and
// destination lie on the schedule's stop list in order — sub-corridor
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
let stops = [dto.originStationId, dto.destinationStationId];
if (targetScheduleId) {
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
if (target) stops = await this.stopYardsForSchedule(target);
bookings.some((b) => {
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
return bookings.some((b) => {
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
const fromIdx = stops.indexOf(b.originYardId);
const toIdx = stops.indexOf(b.destinationYardId);
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
});
})()
const fromIdx = stops.indexOf(b.originYardId);
const toIdx = stops.indexOf(b.destinationYardId);
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
})
) {
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
}
@@ -3956,7 +3986,22 @@ export class TrainSchedulingService {
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
const planned = planWagonsWithStock({ bookings, allowed, stock });
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
// so a ride-along on an empty leg never competes with cargo on a full one.
const legByBookingId = new Map(
bookings.flatMap((b) => {
const from = stops.indexOf(b.originYardId);
const to = stops.indexOf(b.destinationYardId);
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
}),
);
const planned = planWagonsWithStock({
bookings,
allowed,
stock,
legs: legByBookingId,
edgeCount: Math.max(1, stops.length - 1),
});
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
const deferredBookings: DeferredBookingRow[] = planned.deferred;
@@ -4018,10 +4063,11 @@ export class TrainSchedulingService {
).values(),
];
pushLimit(
validateMixedTrainLimits(
validateMixedTrainLimitsPerEdge(
wagonPlan,
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
trainLimits,
stops,
),
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
@@ -6810,12 +6856,33 @@ export class TrainSchedulingService {
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
// Which leg of the corridor this booking rides — the workspace can't
// tell a ride-along (intercity) or sub-corridor booking from through
// cargo without it.
tradeDirection: sb.booking?.tradeDirection ?? null,
originYardId: sb.booking?.originYardId ?? null,
destinationYardId: sb.booking?.destinationYardId ?? null,
origin:
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
destination:
sb.booking?.destinationYard?.label ??
sb.booking?.destinationYard?.code ??
null,
wagonsRequired:
sb.booking?.wagonsRequired != null
? Number(sb.booking.wagonsRequired)
: null,
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the
// booking itself — staff flip it per booking in the workspace before
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
@@ -6824,6 +6891,42 @@ export class TrainSchedulingService {
};
}
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
private mapScheduleStops(
schedule: TrainSchedule,
): Array<{ yardId: string; label: string }> {
const milestones = [...(schedule.route?.milestones ?? [])].sort(
(a, b) => a.sequenceNo - b.sequenceNo,
);
const raw = milestones.length >= 2
? milestones.map((m) => ({
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
}))
: [
{
yardId: schedule.originStationId,
label:
schedule.originStation?.label ??
schedule.originStation?.code ??
schedule.originStationId,
},
{
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
schedule.destinationStationId,
},
];
const seen = new Set<string>();
return raw.filter((stop) => {
if (!stop.yardId || seen.has(stop.yardId)) return false;
seen.add(stop.yardId);
return true;
});
}
private isHoldActive(booking: Booking): boolean {
if (!booking.holdExpiresAt) return false;
return booking.holdExpiresAt.getTime() > Date.now();
@@ -7232,6 +7335,232 @@ export class TrainSchedulingService {
return { id: itemId, containerNumber: dto.containerNumber ?? null };
}
/**
* 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.
*/
async moveContainerItem(
scheduleId: string,
itemId: string,
dto: MoveContainerItemDto,
): 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');
}
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) {
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 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) {
throw new BadRequestException(
`Wagon #${targetWagon.sequenceNo} has no room — a wagon holds one 40ft or two 20ft containers`,
);
}
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);
}
}
await this.dataSource.transaction(async (manager) => {
const items = manager.getRepository(WagonAllocationContainerItem);
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 });
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),
),
});
}
};
await moveOne(item, targetWagon.id, itemWeight);
if (swapItem) {
await moveOne(swapItem, sourceWagon.id, swapWeight);
}
// 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);
});
return this.getTrainScheduleById(scheduleId);
}
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {