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

@@ -570,6 +570,10 @@ export class BookingBatchService implements OnModuleInit {
);
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
// This payment may have been the last live pay window on a now-full
// export day — the settle that normally re-runs the sweep finds nothing
// left to settle, so trigger it here.
void this.expireLeftoverExportDay(booking.trainScheduleId);
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
@@ -2254,6 +2258,11 @@ export class BookingBatchService implements OnModuleInit {
`— payment phase extended for them`,
);
}
// The settle may have resolved the last pay window on a full export day
// (paid → allocated, and the top-up found nothing else that fits) — sweep
// the date's leftover bookings. Self-guarded: no-op for import/domestic
// and while any train on the day can still take bookings.
await this.expireLeftoverExportDay(scheduleId);
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
// this) so one settle produces one push, after every allocation/expiry/
// top-up extension for this schedule has been persisted.
@@ -2350,6 +2359,9 @@ export class BookingBatchService implements OnModuleInit {
);
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
// Same as the webhook path: a staff mark-paid can settle the last live
// pay window on a now-full export day — sweep the date's leftovers.
void this.expireLeftoverExportDay(booking.trainScheduleId);
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
@@ -2461,13 +2473,13 @@ export class BookingBatchService implements OnModuleInit {
if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
// Built trains: collapse to a single train-wide pool so the freed capacity of
// a booking that alights mid-corridor is NOT re-offered on the pass-through
// leg (see remainingBudget). Keeps intercity accept consistent with the
// train-wide isTrainFull / committedWagons finalize signal.
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
collapseForBuiltTrain: true,
});
// Built trains use the leg-aware corridor budget too: the wagon planner
// consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
// that runs empty Gelan→Adama genuinely can carry an intercity booking
// there before its export cargo boards at Adama. A train full on one leg
// still accepts ride-alongs on its empty legs — that is the whole point
// of the ride-along flow.
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
}
@@ -2526,7 +2538,26 @@ export class BookingBatchService implements OnModuleInit {
return;
}
const now = new Date();
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
// EXPORT parity: pay windows on an export train never outlive its booking
// window — export bookings expire at close, so anything reserved onto the
// same train (FCFS export or an intercity ride-along) must too. Import
// keeps the plain payment window; its cycles re-fill after settle.
const targetSchedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (targetSchedule?.direction === "EXPORT") {
const cutoff =
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
if (cutoff && cutoff.getTime() <= now.getTime()) {
throw new BadRequestException(
"Export booking window has closed — cannot open a pay window on this train",
);
}
if (cutoff && cutoff.getTime() < deadline.getTime()) {
deadline = new Date(cutoff);
}
}
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: "SELECTED_FOR_BATCH",
@@ -2822,6 +2853,60 @@ export class BookingBatchService implements OnModuleInit {
return leftovers.length;
}
/**
* EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
* so nothing ever concluded its day: bookings still waiting when the trains
* filled up or the window closed stayed pending forever. Once every export
* train on this route-day is shut — window DONE, or FULL with no pay window
* still live that could lapse and free space — the date is dead: expire the
* un-accepted bookings staff can no longer accept AND the ready
* (FULLY_EXECUTED) bookings that never got a reservation (consolidation
* waiters). Runs at export window close and whenever an export train's
* fullness settles.
*/
async expireLeftoverExportDay(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
return;
}
const day = eatDay(schedule.scheduledDepartureDate);
const trains = (
await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
})
).filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day,
);
for (const s of trains) {
// Any train still taking bookings keeps the date alive.
if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
// A FULL train whose reservations are still inside their pay windows can
// reopen when one lapses unpaid — defer; the settle re-runs this sweep.
if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
return;
}
}
await this.expireUnacceptedForRouteDay({
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day,
});
await this.expireLeftoverDayPool(scheduleId);
}
/**
* Union of stop yards across the day's fillable schedules on this corridor —
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
@@ -3398,7 +3483,6 @@ export class BookingBatchService implements OnModuleInit {
schedule: TrainSchedule,
limits: TrainLimits,
wagonDims: WagonDims,
opts?: { collapseForBuiltTrain?: boolean },
): Promise<CorridorBudget> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
@@ -3411,21 +3495,10 @@ export class BookingBatchService implements OnModuleInit {
tolerance: { weightTons: 0, lengthMeters: 0 },
};
}
// A built train's wagons are coupled for the WHOLE trip, and the allocator
// commits each booking to a wagon for the entire route — it never reloads a
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
// its capacity is one train-wide pool, exactly as isTrainFull /
// committedWagons already count it. When a caller opts in, collapse the
// corridor to a single whole-route edge so every booking (full-route OR
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
// leg it merely passes through, instead of over-promising the freed slots.
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
// abstract slot/weight/length budget genuinely frees past an alight yard.
const stops =
physicalWagons != null && opts?.collapseForBuiltTrain
? [schedule.originStationId, schedule.destinationStationId]
: await this.stopsForSchedule(schedule);
// Built trains keep the leg-aware multi-edge corridor too: the wagon
// planner consumes stock per edge (planWagonsWithStock legs), so a consist
// wagon serves disjoint legs — capacity freed past an alight yard is real.
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)