mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +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:
@@ -58,8 +58,18 @@ type OpenSlot = {
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
/**
|
||||
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
|
||||
* share a slot when their legs are identical — mixing corridors in one slot
|
||||
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
|
||||
* re-occupy edges the cargo never rides.
|
||||
*/
|
||||
legKey: string;
|
||||
};
|
||||
|
||||
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
|
||||
export type BookingLeg = { from: number; to: number };
|
||||
|
||||
type PlacementProblem = {
|
||||
kind: 'config' | 'stock';
|
||||
message: string;
|
||||
@@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
|
||||
const shortageFor = (
|
||||
booking: Booking,
|
||||
candidates: WagonType[],
|
||||
remaining: Map<string, number>,
|
||||
availableOf: (wagonTypeId: string) => number,
|
||||
): BookingWagonShortage => {
|
||||
const wagonsNeeded =
|
||||
booking.freightType === 'BULK'
|
||||
@@ -100,7 +110,7 @@ const shortageFor = (
|
||||
)
|
||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
const wagonsAvailable = candidates.reduce(
|
||||
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
|
||||
(sum, wt) => sum + availableOf(wt.id),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
@@ -140,14 +150,53 @@ export function planWagonsWithStock(params: {
|
||||
bookings: Booking[];
|
||||
allowed: AllowedWagonTypeMap;
|
||||
stock: WagonStock;
|
||||
/**
|
||||
* Leg-aware stock: booking id → the stop-index range it rides. When given
|
||||
* (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so
|
||||
* the same physical wagon can serve an intercity booking on Gelan→Adama and
|
||||
* an export booking on Adama→Doraleh — disjoint legs never compete for
|
||||
* stock. Omitted → one edge, byte-identical to the old whole-route behavior.
|
||||
*/
|
||||
legs?: Map<string, BookingLeg>;
|
||||
edgeCount?: number;
|
||||
}): FlexPlanResult {
|
||||
const { bookings, allowed, stock } = params;
|
||||
const remaining = new Map(stock.remainingByTypeId);
|
||||
const { bookings, allowed, stock, legs } = params;
|
||||
const edgeCount = Math.max(1, params.edgeCount ?? 1);
|
||||
const openSlots: OpenSlot[] = [];
|
||||
const fitting: Booking[] = [];
|
||||
const deferred: DeferredBookingRow[] = [];
|
||||
const configIssues = new Set<string>();
|
||||
|
||||
const legFor = (booking: Booking): BookingLeg => {
|
||||
const leg = legs?.get(booking.id);
|
||||
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
|
||||
return { from: 0, to: edgeCount };
|
||||
}
|
||||
return leg;
|
||||
};
|
||||
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
|
||||
|
||||
// Wagons of a type in use per corridor edge. A type is available for a leg
|
||||
// when its busiest edge WITHIN that leg still has stock spare — the max over
|
||||
// edges is the number of physical wagons the type needs simultaneously.
|
||||
const usedPerEdge = new Map<string, number[]>();
|
||||
const usedRow = (wagonTypeId: string): number[] => {
|
||||
let row = usedPerEdge.get(wagonTypeId);
|
||||
if (!row) {
|
||||
row = new Array<number>(edgeCount).fill(0);
|
||||
usedPerEdge.set(wagonTypeId, row);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||
const row = usedPerEdge.get(wagonTypeId);
|
||||
if (!row) return total;
|
||||
let busiest = 0;
|
||||
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
|
||||
return total - busiest;
|
||||
};
|
||||
|
||||
const noStockMessage = (candidates: WagonType[]): string => {
|
||||
const codes = candidates.map((wt) => wt.code).join('/');
|
||||
return stock.mode === 'TRAIN'
|
||||
@@ -155,13 +204,14 @@ export function planWagonsWithStock(params: {
|
||||
: `No available ${codes} wagon at the yard`;
|
||||
};
|
||||
|
||||
/** Open a new wagon of one of the candidate types, consuming stock. */
|
||||
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
|
||||
const openSlot = (
|
||||
candidates: WagonType[],
|
||||
kind: SlotLoadType,
|
||||
cargoTypeId: string | null,
|
||||
leg: BookingLeg,
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
||||
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||||
if (!inStock.length) {
|
||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
||||
}
|
||||
@@ -170,22 +220,26 @@ export function planWagonsWithStock(params: {
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
kind === 'BULK'
|
||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
|
||||
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
|
||||
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||||
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||
)[0];
|
||||
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
|
||||
const row = usedRow(chosen.id);
|
||||
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
||||
const open: OpenSlot = {
|
||||
slot: slotFromWagonType(chosen, kind),
|
||||
teuUsed: 0,
|
||||
kind,
|
||||
cargoTypeId,
|
||||
freeCapacityTons: Number(chosen.capacityTons),
|
||||
legKey: legKeyOf(leg),
|
||||
};
|
||||
openSlots.push(open);
|
||||
return open;
|
||||
};
|
||||
|
||||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||
const leg = legFor(booking);
|
||||
const legKey = legKeyOf(leg);
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
if (!units.length) {
|
||||
@@ -209,11 +263,12 @@ export function planWagonsWithStock(params: {
|
||||
let target = openSlots.find(
|
||||
(open) =>
|
||||
open.kind === 'CONTAINER' &&
|
||||
open.legKey === legKey &&
|
||||
allowedIds.has(open.slot.wagonTypeId) &&
|
||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
||||
);
|
||||
if (!target) {
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null);
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
target = openedSlot;
|
||||
}
|
||||
@@ -246,6 +301,7 @@ export function planWagonsWithStock(params: {
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.legKey !== legKey) continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
@@ -263,7 +319,7 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
@@ -282,7 +338,9 @@ export function planWagonsWithStock(params: {
|
||||
|
||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||||
const remainingSnapshot = new Map(remaining);
|
||||
const usedSnapshot = new Map(
|
||||
[...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]),
|
||||
);
|
||||
const slotCountSnapshot = openSlots.length;
|
||||
const slotStateSnapshot = openSlots.map((open) => ({
|
||||
teuUsed: open.teuUsed,
|
||||
@@ -299,8 +357,8 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
|
||||
// Roll back this booking's partial placements.
|
||||
remaining.clear();
|
||||
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
|
||||
usedPerEdge.clear();
|
||||
for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value);
|
||||
openSlots.length = slotCountSnapshot;
|
||||
openSlots.forEach((open, index) => {
|
||||
const snap = slotStateSnapshot[index];
|
||||
@@ -315,11 +373,14 @@ export function planWagonsWithStock(params: {
|
||||
});
|
||||
|
||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||
// remaining is rolled back here, so the shortage counts the stock this
|
||||
// Usage is rolled back here, so the shortage counts the stock this
|
||||
// booking actually saw — not what its own partial placement consumed.
|
||||
const bookingLeg = legFor(booking);
|
||||
const shortage =
|
||||
problem.kind === 'stock' && problem.candidates?.length
|
||||
? shortageFor(booking, problem.candidates, remaining)
|
||||
? shortageFor(booking, problem.candidates, (wagonTypeId) =>
|
||||
Math.max(0, availableFor(wagonTypeId, bookingLeg)),
|
||||
)
|
||||
: null;
|
||||
deferred.push({
|
||||
id: booking.id,
|
||||
|
||||
Reference in New Issue
Block a user