mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user