From acef6870e9cb86748b123b58c349d3be7065e4a0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 30 Jul 2026 17:21:44 +0000 Subject: [PATCH] leg-aware capacity and wagon sharing --- .../train-scheduling/booking-batch.service.ts | 169 ++++++++++++++++-- .../train-scheduling.service.ts | 38 +++- .../wagon-plan-flex.util.spec.ts | 39 ++-- .../train-scheduling/wagon-plan-flex.util.ts | 87 +++++++-- .../train-scheduling/wagon-plan.util.ts | 52 +++++- .../trainScheduling/AllocateBookingWizard.tsx | 1 + .../trainScheduling/PriorityTrackingTab.tsx | 152 +++++++++++++--- .../TrainCompositionDiagram.tsx | 49 +++-- .../InteractiveTrainConsist.tsx | 13 +- .../compositionEditor/TrainConsistView.tsx | 19 +- .../BatchScheduleDetailPage.tsx | 121 ++++++++++++- .../TrainScheduleV2DetailPage.tsx | 12 ++ .../backoffice/src/types/trainScheduling.ts | 7 +- packages/types/src/freight/index.ts | 4 + .../ExportTrainPicker/ExportTrainPicker.tsx | 9 +- 15 files changed, 653 insertions(+), 119 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 9f05192c3..92f96dfa5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -127,6 +127,10 @@ export interface ExportSpaceReport { */ export interface ExportTrainOption { scheduleId: string; + /** Schedule's train number (falls back to the built train's number). */ + trainNumber: string | null; + /** Built train's name/code, when the schedule runs a Train Builder train. */ + trainName: string | null; departure: Date; /** Booking cutoff for this train (windowClosesAt), null on legacy rows. */ bookingClosesAt: Date | null; @@ -297,11 +301,20 @@ export interface BatchBoardSchedule { /** Train length used by allocated bookings (from wagon-type dimensions). */ allocatedLengthMeters: number; maxLengthMeters: number | null; - /** Weight committed on the train (allocated + selected-for-batch). */ + /** + * Weight committed on the train (allocated + selected-for-batch). On a + * multi-stop corridor this is the HEAVIEST single edge, not the sum — + * disjoint legs (intercity + export) never ride together, so summing + * them over-reports the train against the pull limit. + */ usedWeightTons: number; maxWeightTons: number | null; /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ maxWagons: number | null; + /** Physical consist length of the built train (Train Builder), null without one. */ + trainLengthMeters: number | null; + /** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */ + legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null; }; counts: { allocated: number; @@ -1076,21 +1089,52 @@ export class BookingBatchService implements OnModuleInit { const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg const room = budget.remainingFor(leg); - const byWagonType = allowed.map(({ wagonTypeId, dims }) => { - const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; - return { - wagonTypeId, - code: type?.code ?? null, - name: type?.name ?? null, - freeWagons: this.bookableWithin(room, dims).wagons, - }; - }); + // The abstract budget can't tell wagon types apart — cap each type's free + // count with the PHYSICAL wagons of that type the train (or yard pool) + // actually holds on this leg, and on a built train hide types the consist + // doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free". + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + const ledger = new WagonStockLedger( + stock.remainingByTypeId, + Math.max(1, budget.stops.length - 1), + ); + const byWagonType = allowed + .filter( + ({ wagonTypeId }) => + stock.mode !== 'TRAIN' || + !wagonTypeId || + (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0, + ) + .map(({ wagonTypeId, dims }) => { + const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; + const roomWagons = this.bookableWithin(room, dims).wagons; + const physical = wagonTypeId + ? ledger.availableFor([wagonTypeId], leg) + : roomWagons; + return { + wagonTypeId, + code: type?.code ?? null, + name: type?.name ?? null, + freeWagons: Math.min(roomWagons, physical), + }; + }); const freeWagons = byWagonType.reduce( (best, t) => Math.max(best, t.freeWagons), 0, ); + const builtTrain = schedule.trainSet?.train; out.push({ scheduleId: schedule.id, + trainNumber: + schedule.trainNumber ?? + builtTrain?.exportTrainNumber ?? + builtTrain?.trainNumber ?? + null, + trainName: builtTrain?.trainName ?? builtTrain?.code ?? null, departure: schedule.scheduledDepartureDate!, bookingClosesAt: schedule.windowClosesAt ?? null, isOpen: this.isFillable(schedule), @@ -1531,9 +1575,18 @@ export class BookingBatchService implements OnModuleInit { // waiting pool (the 7 that lost the batch), not just the winners. These are // display-only candidates: they are excluded from the capacity meters below. const pinnedIds = new Set(bookings.map((b) => b.id)); - if (s.scheduledDepartureDate) { + // Corridor stops drive both the day-pool candidate merge and the per-leg + // capacity meters below; a failed lookup degrades to whole-route math. + let stops: string[] = []; + try { + stops = await this.stopsForSchedule(s); + } catch (err) { + this.logger.warn( + `Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`, + ); + } + if (s.scheduledDepartureDate && stops.length) { try { - const stops = await this.stopsForSchedule(s); const candidates = await this.bookingsRepository.findBatchPoolByCorridorDay( stops, @@ -1662,6 +1715,18 @@ export class BookingBatchService implements OnModuleInit { const windowBookings = items.filter((i) => i.fullyExecutedAt); const pendingBookings = items.filter((i) => !i.fullyExecutedAt); + const stopLabels = + stops.length > 2 ? await this.yardLabels(stops) : new Map(); + const yardsByBookingId = new Map( + bookings.map((b) => [ + b.id, + { + originYardId: b.originYardId ?? null, + destinationYardId: b.destinationYardId ?? null, + }, + ]), + ); + return { scheduleId: s.id, scheduleReference: s.reference ?? null, @@ -1707,6 +1772,12 @@ export class BookingBatchService implements OnModuleInit { items.filter((i) => pinnedIds.has(i.id)), loco, s.maxWagons ?? null, + { + stops, + labelByYardId: stopLabels, + yardsByBookingId, + trainLengthMeters: this.builtTrainLengthOf(s), + }, ), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, @@ -1747,6 +1818,7 @@ export class BookingBatchService implements OnModuleInit { */ private computeBoardCapacity( items: Array<{ + id: string; state: BatchBoardBookingState; wagons: number; weightTons: number; @@ -1754,6 +1826,16 @@ export class BookingBatchService implements OnModuleInit { }>, loco: LocomotiveLimits | null, maxWagons: number | null, + legCtx?: { + /** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */ + stops: string[]; + labelByYardId: Map; + yardsByBookingId: Map< + string, + { originYardId: string | null; destinationYardId: string | null } + >; + trainLengthMeters: number | null; + }, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); // Every booking still targeting this train holds gross weight — including @@ -1771,18 +1853,72 @@ export class BookingBatchService implements OnModuleInit { : null; const round2 = (value: number) => Math.round(value * 100) / 100; + // Per-leg committed weight: a booking holds weight only on the edges it + // rides, so the meter compares the HEAVIEST single edge against the pull + // limit. Whole-route bookings (or yards missing from the stop list) load + // every edge — never under-reported. + const stops = legCtx?.stops ?? []; + let usedWeightTons = round2( + committed.reduce((sum, i) => sum + i.weightTons, 0), + ); + let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null; + if (legCtx && stops.length > 2) { + const stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + const edges = new Array(stops.length - 1).fill(0); + for (const item of committed) { + const yards = legCtx.yardsByBookingId.get(item.id); + const from = yards?.originYardId + ? stopIndex.get(yards.originYardId) + : undefined; + const to = yards?.destinationYardId + ? stopIndex.get(yards.destinationYardId) + : undefined; + const leg = + from != null && to != null && from < to + ? { from, to } + : { from: 0, to: edges.length }; + for (let e = leg.from; e < leg.to; e += 1) edges[e] += item.weightTons; + } + const label = (yardId: string) => + legCtx.labelByYardId.get(yardId) ?? yardId; + legUsage = edges.map((weight, i) => ({ + from: label(stops[i]), + to: label(stops[i + 1]), + usedWeightTons: round2(weight), + })); + usedWeightTons = round2(Math.max(0, ...edges)); + } + return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedLengthMeters: round2( allocated.reduce((sum, i) => sum + i.lengthMeters, 0), ), maxLengthMeters: caps ? caps.maxLengthMeters : null, - usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), + usedWeightTons, maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, + trainLengthMeters: legCtx?.trainLengthMeters ?? null, + legUsage, }; } + /** Built consist's physical length (what Train Builder shows), null without a built train. */ + private builtTrainLengthOf(s: TrainSchedule): number | null { + const raw = s.trainSet?.totalLengthMeters; + const value = raw != null ? Number(raw) : NaN; + return Number.isFinite(value) && value > 0 ? value : null; + } + + /** Yard display labels for corridor stops (falls back to the yard id). */ + private async yardLabels(yardIds: string[]): Promise> { + if (!yardIds.length) return new Map(); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(yardIds) } }); + return new Map(yards.map((y) => [y.id, y.label ?? y.code])); + } + private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], @@ -1829,7 +1965,12 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, { + stops: [], + labelByYardId: new Map(), + yardsByBookingId: new Map(), + trainLengthMeters: this.builtTrainLengthOf(s), + }), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index ccab4ebc6..387674dbc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -4139,6 +4139,7 @@ export class TrainSchedulingService { fittingBookings, dto.originStationId, dto.destinationStationId, + stops, ); violations.push( @@ -4185,6 +4186,8 @@ export class TrainSchedulingService { wagonPlan, containerPlacements, placementRules, + legByBookingId, + Math.max(1, stops.length - 1), ), ); violations.push( @@ -5011,6 +5014,7 @@ export class TrainSchedulingService { bookings: Booking[], scheduleOriginYardId: string, scheduleDestinationYardId: string, + stops: string[], ): void { const bookingById = new Map(bookings.map((b) => [b.id, b])); for (const slot of wagonPlan) { @@ -5026,13 +5030,33 @@ export class TrainSchedulingService { b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId, ); - if (!sameCorridor) continue; - slot.boardYardId = - first.originYardId === scheduleOriginYardId ? null : first.originYardId; - slot.alightYardId = - first.destinationYardId === scheduleDestinationYardId - ? null - : first.destinationYardId; + if (sameCorridor) { + slot.boardYardId = + first.originYardId === scheduleOriginYardId ? null : first.originYardId; + slot.alightYardId = + first.destinationYardId === scheduleDestinationYardId + ? null + : first.destinationYardId; + continue; + } + // Mixed corridors on one wagon (cross-leg TEU sharing): the wagon rides + // the UNION of its cargo legs. A yard missing from the stop list keeps + // the slot on the whole route so capacity is never under-occupied. + let from = Number.POSITIVE_INFINITY; + let to = Number.NEGATIVE_INFINITY; + for (const b of slotBookings) { + const f = stops.indexOf(b.originYardId); + const t = stops.indexOf(b.destinationYardId); + if (f < 0 || t <= f) { + from = Number.POSITIVE_INFINITY; + break; + } + from = Math.min(from, f); + to = Math.max(to, t); + } + if (!Number.isFinite(from) || to <= from) continue; + slot.boardYardId = from === 0 ? null : stops[from]; + slot.alightYardId = to === stops.length - 1 ? null : stops[to]; } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 778dc70dd..691525efa 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -198,7 +198,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => new Map(entries); it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => { - // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only. + // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only: + // the intercity 20ft alights where the export 20ft boards, so both share + // the single physical wagon (cross-leg TEU sharing). const result = planWagonsWithStock({ bookings: [ containerBooking('EXPORT-1', 1, 1), @@ -222,16 +224,19 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => 'EXPORT-1', 'INTERCITY-1', ]); - // Two slots planned, but both drawn from the single physical wagon. - expect(result.plan).toHaveLength(2); + expect(result.plan).toHaveLength(1); }); - it('still defers when the legs overlap and stock is exhausted', () => { + it('still defers when the wagon has no per-edge TEU room and stock is exhausted', () => { + // Export is a 40ft (2 TEU) riding the whole corridor — no edge has room + // for the intercity 20ft, and there is no second wagon to open. + const fortyFooter = containerBooking('EXPORT-1', 1, 1); + fortyFooter.bookingContainers![0]!.containerType = { + code: '40GP', + sizeFt: 40, + } as never; const result = planWagonsWithStock({ - bookings: [ - containerBooking('EXPORT-1', 1, 1), - containerBooking('INTERCITY-1', 1, 1), - ], + bookings: [fortyFooter, containerBooking('INTERCITY-1', 1, 1)], allowed, stock: { mode: 'TRAIN', @@ -239,7 +244,6 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => codesByTypeId: new Map([[nw6.id, nw6.code]]), }, legs: legs([ - // Both ride edge 0 — they compete for the one wagon. ['EXPORT-1', { from: 0, to: 2 }], ['INTERCITY-1', { from: 0, to: 1 }], ]), @@ -252,9 +256,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left'); }); - it('never packs bookings with different legs into the same wagon slot', () => { - // Two 20ft units with room to share one wagon by TEU — but disjoint legs - // must open separate slots (each with its own leg), not one mixed slot. + it('packs disjoint-leg 20fts onto one wagon instead of appending a second', () => { + // Two 20ft units, two wagons in stock — cross-leg TEU sharing still fills + // the open wagon (span grows to the union) rather than opening wagon #2. const result = planWagonsWithStock({ bookings: [ containerBooking('EXPORT-1', 1, 1), @@ -273,11 +277,12 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => edgeCount: 2, }); - expect(result.plan).toHaveLength(2); - const bookingsPerSlot = result.plan.map((s) => - [...new Set(s.allocations.map((a) => a.bookingId))].sort(), - ); - expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]); + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(1); + const bookingsInSlot = [ + ...new Set(result.plan[0]!.allocations.map((a) => a.bookingId)), + ].sort(); + expect(bookingsInSlot).toEqual(['EXPORT-1', 'INTERCITY-1']); }); it('behaves exactly like the whole-route planner when no legs are given', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 699d7a432..84293d475 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -53,18 +53,25 @@ export type FlexPlanResult = { type OpenSlot = { slot: WagonPlanSlot; - teuUsed: number; + /** + * TEU occupied PER CORRIDOR EDGE. Containers on different legs share the + * same physical wagon as long as no single edge exceeds the wagon's TEU + * geometry — an intercity 20ft alighting at Adama frees its slot for a 20ft + * boarding there, and two overlapping-leg 20fts coexist while both ride. + */ + teuPerEdge: number[]; kind: SlotLoadType; /** 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. + * Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers + * prefer a same-leg slot but may extend onto a different-leg one (span + * grows to the union); bulk still shares only on an identical leg. */ legKey: string; + /** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */ + covered: { from: number; to: number }; }; /** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */ @@ -227,16 +234,54 @@ export function planWagonsWithStock(params: { 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, + teuPerEdge: new Array(edgeCount).fill(0), kind, cargoTypeId, freeCapacityTons: Number(chosen.capacityTons), legKey: legKeyOf(leg), + covered: { ...leg }, }; openSlots.push(open); return open; }; + /** TEU room on every edge of the unit's leg. */ + const teuFits = (open: OpenSlot, leg: BookingLeg, teu: number): boolean => { + for (let e = leg.from; e < leg.to; e += 1) { + if ((open.teuPerEdge[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) return false; + } + return true; + }; + + /** + * Whether the slot's ridden span can grow to include this leg: every NEW + * edge (outside the current span) must still have a physical wagon of the + * slot's type spare — extending the span puts this wagon on those edges. + */ + const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => { + const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0; + const row = usedPerEdge.get(open.slot.wagonTypeId); + const from = Math.min(open.covered.from, leg.from); + const to = Math.max(open.covered.to, leg.to); + for (let e = from; e < to; e += 1) { + if (e >= open.covered.from && e < open.covered.to) continue; + if (total - (row?.[e] ?? 0) <= 0) return false; + } + return true; + }; + + /** Grow the slot's span onto the leg's new edges, consuming stock there. */ + const extendSpan = (open: OpenSlot, leg: BookingLeg): void => { + const row = usedRow(open.slot.wagonTypeId); + const from = Math.min(open.covered.from, leg.from); + const to = Math.max(open.covered.to, leg.to); + for (let e = from; e < to; e += 1) { + if (e >= open.covered.from && e < open.covered.to) continue; + row[e] = (row[e] ?? 0) + 1; + } + open.covered = { from, to }; + }; + const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { const leg = legFor(booking); const legKey = legKeyOf(leg); @@ -260,17 +305,23 @@ export function planWagonsWithStock(params: { } const allowedIds = new Set(candidates.map((wt) => wt.id)); const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); - let target = openSlots.find( - (open) => - open.kind === 'CONTAINER' && - open.legKey === legKey && - allowedIds.has(open.slot.wagonTypeId) && - open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, - ); + const fitsSlot = (open: OpenSlot): boolean => + open.kind === 'CONTAINER' && + allowedIds.has(open.slot.wagonTypeId) && + teuFits(open, leg, teu) && + canExtendSpan(open, leg); + // Same-leg slots first (keeps legacy packing byte-identical), then any + // open wagon with per-edge TEU room — an intercity 20ft rides an + // export wagon's spare slot instead of appending a new wagon. + let target = + openSlots.find((open) => open.legKey === legKey && fitsSlot(open)) ?? + openSlots.find(fitsSlot); if (!target) { const openedSlot = openSlot(candidates, 'CONTAINER', null, leg); if ('message' in openedSlot) return openedSlot; target = openedSlot; + } else { + extendSpan(target, leg); } addAllocation( target.slot, @@ -279,7 +330,9 @@ export function planWagonsWithStock(params: { unit.grossWeightTons, AllocationLoadType.Container, ); - target.teuUsed += teu; + for (let e = leg.from; e < leg.to; e += 1) { + target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu; + } } return null; } @@ -343,7 +396,8 @@ export function planWagonsWithStock(params: { ); const slotCountSnapshot = openSlots.length; const slotStateSnapshot = openSlots.map((open) => ({ - teuUsed: open.teuUsed, + teuPerEdge: [...open.teuPerEdge], + covered: { ...open.covered }, freeCapacityTons: open.freeCapacityTons, assignedWeightTons: open.slot.assignedWeightTons, allocationCount: open.slot.allocations.length, @@ -363,7 +417,8 @@ export function planWagonsWithStock(params: { openSlots.forEach((open, index) => { const snap = slotStateSnapshot[index]; if (!snap) return; - open.teuUsed = snap.teuUsed; + open.teuPerEdge = [...snap.teuPerEdge]; + open.covered = { ...snap.covered }; open.freeCapacityTons = snap.freeCapacityTons; open.slot.assignedWeightTons = snap.assignedWeightTons; open.slot.allocations.length = snap.allocationCount; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index eadd0b471..2b88bb903 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -665,6 +665,14 @@ export function validateContainerPlacements( wagonPlan: WagonPlanSlot[], placements: ContainerPlacementInput[], rules?: ContainerPlacementRules, + /** + * Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg. + * With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity + * 20ft and an export 20ft coexist on one wagon when their edges allow it. + * Omitted → one edge, byte-identical to the whole-route check. + */ + legs?: Map, + edgeCount?: number, ): string[] { const violations: string[] = []; const units = expandBookingContainerUnits(containerBookings); @@ -721,8 +729,18 @@ export function validateContainerPlacements( } } - const slotTeuUsed = new Map(); - const slotWeightUsed = new Map(); + // TEU and weight are tracked PER EDGE of a unit's leg; without legs there is + // a single edge and this is exactly the old whole-route accounting. + const edges = Math.max(1, edgeCount ?? 1); + const legOf = (bookingId: string): { from: number; to: number } => { + const leg = legs?.get(bookingId); + if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) { + return { from: 0, to: edges }; + } + return leg; + }; + const slotTeuUsed = new Map(); + const slotWeightUsed = new Map(); const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s])); for (const placement of placements) { @@ -734,22 +752,38 @@ export function validateContainerPlacements( if (!unit) continue; const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); - const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0; - if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) { + const leg = legOf(unit.bookingId); + const teuRow = + slotTeuUsed.get(placement.sequenceNo) ?? new Array(edges).fill(0); + let teuFits = true; + for (let e = leg.from; e < leg.to; e += 1) { + if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) { + teuFits = false; + break; + } + } + if (!teuFits) { violations.push( `Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`, ); } else { - slotTeuUsed.set(placement.sequenceNo, usedTeu + teu); + for (let e = leg.from; e < leg.to; e += 1) teuRow[e] = (teuRow[e] ?? 0) + teu; + slotTeuUsed.set(placement.sequenceNo, teuRow); } const slot = slotBySeq.get(placement.sequenceNo); if (slot) { - const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons; - slotWeightUsed.set(placement.sequenceNo, weight); - if (weight > slot.capacityTons) { + const weightRow = + slotWeightUsed.get(placement.sequenceNo) ?? new Array(edges).fill(0); + let heaviestEdge = 0; + for (let e = leg.from; e < leg.to; e += 1) { + weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons); + heaviestEdge = Math.max(heaviestEdge, weightRow[e]); + } + slotWeightUsed.set(placement.sequenceNo, weightRow); + if (heaviestEdge > slot.capacityTons) { violations.push( - `Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`, + `Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`, ); } } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index ec4e1b5bc..169d49394 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -739,6 +739,7 @@ export function AllocateBookingWizard({ {displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? ( + n === 1 ? "1st" : n === 2 ? "2nd" : n === 3 ? "3rd" : `${n}th`; + +function groupMeta(b: BatchBoardBookingDetail): Omit { + if (b.isGovernment) + return { key: "gov", color: "grape", label: "Government", sub: "boards first" }; + if (b.windowCycleNo == null) + return { + key: "none", + color: "gray", + label: "No cycle yet", + sub: "contract not signed", + }; + const n = b.windowCycleNo + 1; + return { + key: `c${b.windowCycleNo}`, + color: CYCLE_COLORS[b.windowCycleNo % CYCLE_COLORS.length], + label: `${ordinal(n)} cycle window`, + sub: n === 1 ? "booked in the first window" : "boards after earlier cycles", + }; +} + +function groupByCycle(items: BatchBoardBookingDetail[]): CycleGroup[] { + const groups: CycleGroup[] = []; + for (const b of items) { + const meta = groupMeta(b); + const last = groups[groups.length - 1]; + if (last && last.key === meta.key) last.items.push(b); + else groups.push({ ...meta, items: [b] }); + } + return groups; +} + +/** Tinted wrapper card holding one cycle's ranked bookings. */ +function CycleSection({ + group, + children, +}: { + group: CycleGroup; + children: ReactNode; +}) { + const wagons = group.items.reduce((s, b) => s + b.wagons, 0); + return ( + + + + {group.key === "gov" ? : } + + + {group.label} + + {group.sub ? ( + + — {group.sub} + + ) : null} + + {group.items.length} booking{group.items.length === 1 ? "" : "s"} ·{" "} + {wagons}w + + + {children} + + ); +} + /** The capacity cut line drawn between "in the batch" and "waiting list". */ function CapacityDivider({ used, max }: { used: number; max: number | null }) { const full = max != null && used >= max; @@ -482,19 +572,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({ - {lanes.inBatch.map((b) => { - rankNo += 1; - return ( - - ); - })} + {groupByCycle(lanes.inBatch).map((g) => ( + + {g.items.map((b) => { + rankNo += 1; + return ( + + ); + })} + + ))} ) : null} @@ -514,19 +608,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({ - {lanes.waiting.map((b) => { - rankNo += 1; - return ( - - ); - })} + {groupByCycle(lanes.waiting).map((g) => ( + + {g.items.map((b) => { + rankNo += 1; + return ( + + ); + })} + + ))} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index af8917e00..ee09b0a6e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -288,8 +288,11 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : "" }`; - // container blocks: one per container number (cap visual at 2 = TEU per wagon) - const blocks = wagon.containerNumbers.slice(0, 2); + // container blocks: one per container number, up to 4 — cross-leg TEU + // sharing can put two 20ft pairs (riding different legs) on one wagon. + // 1–2 sit side by side; 3–4 form a 2×2 grid (two rows, up/down). + const blocks = wagon.containerNumbers.slice(0, 4); + const twoRows = blocks.length > 2; return ( @@ -376,15 +379,21 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { ) : ( - + 1 ? "1fr 1fr" : "1fr", + gap: 3, + }} + > {(blocks.length ? blocks : ["—"]).map((cn, i) => ( - {/* corrugation lines */} - - + {/* corrugation lines — dropped in two-row mode, no room */} + {!twoRows ? ( + + ) : null} + {cn} ))} - + )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 23cf35877..badf4a73d 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -26,6 +26,8 @@ type DragState = { sourceWagonId: string } | null; interface InteractiveTrainConsistProps { wagons: Wagon[]; locomotive: Locomotive | null | undefined; + /** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */ + locomotives?: NonNullable["locomotives"] | null; /** Resolve the customer/company name for a booking id (joined from schedule bookings). */ getCompany: (bookingId: string | undefined) => string | null; selectedWagonId: string | null; @@ -557,6 +559,7 @@ function WagonCar({ export const InteractiveTrainConsist = ({ wagons, locomotive, + locomotives, getCompany, selectedWagonId, onSelectWagon, @@ -565,6 +568,7 @@ export const InteractiveTrainConsist = ({ onMoveLoad, }: InteractiveTrainConsistProps) => { const [drag, setDrag] = useState(null); + const locos = locomotives?.length ? locomotives : locomotive ? [locomotive] : []; return ( - {locomotive ? : null} + {locos.map((loco, i) => ( + + {i > 0 ? : null} + + + ))} {wagons.length === 0 ? ( No wagons assigned @@ -599,7 +608,7 @@ export const InteractiveTrainConsist = ({ const bookingId = wagon.allocations?.[0]?.bookingId; return ( - {i > 0 || locomotive ? : null} + {i > 0 || locos.length ? : null} sum + (w.lengthMeters ?? 0), 0); + // Weakest locomotive caps the set — same rule the allocation engine applies. + const locos = trainSet?.locomotives?.length + ? trainSet.locomotives + : trainSet?.locomotive + ? [trainSet.locomotive] + : []; + const weightMax = locos.length + ? Math.min(...locos.map((l) => l.maxPullWeightTons)) + : null; + const lengthCaps = locos + .map((l) => l.maxTrainLengthMeters) + .filter((v): v is number => v != null); + const lengthMax = lengthCaps.length ? Math.min(...lengthCaps) : null; + return ( @@ -202,6 +216,7 @@ export const TrainConsistView = ({ (bookingId ? companyByBooking.get(bookingId) ?? null : null)} selectedWagonId={selectedWagonId} onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 1e3735961..bafaf4602 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -9,6 +9,7 @@ import { Group, Loader, Paper, + Progress, Stack, Tabs, Text, @@ -923,9 +924,19 @@ export default function BatchScheduleDetailPage() { items={[ { label: "Train length", - value: data.capacity.maxLengthMeters - ? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}` - : fmtMeters(data.capacity.allocatedLengthMeters), + // A built train's length is its marshalled consist — always + // the same figure the Train Builder shows. + value: (() => { + const length = + data.capacity.trainLengthMeters ?? + data.capacity.allocatedLengthMeters; + return data.capacity.maxLengthMeters + ? `${fmtMeters(length)} / ${fmtMeters(data.capacity.maxLengthMeters)}` + : fmtMeters(length); + })(), + hint: data.capacity.trainLengthMeters + ? "built consist — matches Train Builder" + : undefined, icon: Ruler, }, { @@ -933,9 +944,24 @@ export default function BatchScheduleDetailPage() { value: data.capacity.maxWeightTons ? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}` : fmtTons(data.capacity.usedWeightTons), - hint: "wagon tare + cargo", + hint: (() => { + const legs = data.capacity.legUsage; + if (!legs?.length) return "wagon tare + cargo"; + const peak = legs.reduce((a, b) => + b.usedWeightTons > a.usedWeightTons ? b : a, + ); + return `peak leg ${peak.from} → ${peak.to} · wagon tare + cargo`; + })(), icon: Weight, }, + { + label: "Wagons", + value: data.capacity.maxWagons + ? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}` + : data.capacity.allocatedWagons, + hint: "allocated wagon slots", + icon: Layers, + }, { label: "Bookings", value: totalBookings, @@ -945,6 +971,92 @@ export default function BatchScheduleDetailPage() { ]} /> + {/* Per-leg load — only multi-stop corridors have distinct legs */} + {data.capacity.legUsage && data.capacity.legUsage.length > 1 ? ( + + + + + +
+ Load per leg + + Each leg carries only the bookings riding it — the heaviest + leg is what the locomotive actually pulls. + +
+
+ + {(() => { + const legs = data.capacity.legUsage; + const max = data.capacity.maxWeightTons; + const peakTons = Math.max( + ...legs.map((l) => l.usedWeightTons), + ); + return legs.map((leg, i) => { + const pct = max + ? Math.round((leg.usedWeightTons / max) * 100) + : null; + const over = pct != null && pct > 100; + const isPeak = + peakTons > 0 && leg.usedWeightTons === peakTons; + return ( + + + + {leg.from} → {leg.to} + + {isPeak ? ( + + peak + + ) : null} + + + {fmtTons(leg.usedWeightTons)} + {max ? ` / ${fmtTons(max)}` : ""} + {pct != null ? ` · ${pct}%` : ""} + + {pct != null ? ( + 90 ? "yellow" : "edr-green"} + size="sm" + radius="xl" + striped={over} + animated={over} + /> + ) : null} + + ); + }); + })()} + +
+ ) : null} + {/* Booking pipeline */} | null; }; counts: { allocated: number; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index c4911c20f..5371f56fa 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -1108,6 +1108,10 @@ export interface ExportTrainOptionWagonType { */ export interface ExportTrainOption { scheduleId: string; + /** Schedule's train number (falls back to the built train's number). */ + trainNumber: string | null; + /** Built train's name/code, when the schedule runs a Train Builder train. */ + trainName: string | null; departure: string; bookingClosesAt: string | null; isOpen: boolean; diff --git a/packages/ui-common/src/components/ExportTrainPicker/ExportTrainPicker.tsx b/packages/ui-common/src/components/ExportTrainPicker/ExportTrainPicker.tsx index 5a167cdd0..3ba38a3c8 100644 --- a/packages/ui-common/src/components/ExportTrainPicker/ExportTrainPicker.tsx +++ b/packages/ui-common/src/components/ExportTrainPicker/ExportTrainPicker.tsx @@ -89,7 +89,14 @@ export function ExportTrainPicker({ - Departs {departureLabel(option.departure)} EAT + {option.trainNumber + ? `Train ${option.trainNumber}` + : (option.trainName ?? "Train")} + {option.trainNumber && option.trainName + ? ` · ${option.trainName}` + : ""} + {" — departs "} + {departureLabel(option.departure)} EAT {option.freeWagons} wagon{option.freeWagons === 1 ? "" : "s"} free