mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
Merge pull request #1030 from Tria-plc/freight_feature/usermanagement
leg-aware capacity and wagon sharing
This commit is contained in:
@@ -127,6 +127,10 @@ export interface ExportSpaceReport {
|
|||||||
*/
|
*/
|
||||||
export interface ExportTrainOption {
|
export interface ExportTrainOption {
|
||||||
scheduleId: string;
|
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;
|
departure: Date;
|
||||||
/** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
|
/** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
|
||||||
bookingClosesAt: Date | null;
|
bookingClosesAt: Date | null;
|
||||||
@@ -297,11 +301,20 @@ export interface BatchBoardSchedule {
|
|||||||
/** Train length used by allocated bookings (from wagon-type dimensions). */
|
/** Train length used by allocated bookings (from wagon-type dimensions). */
|
||||||
allocatedLengthMeters: number;
|
allocatedLengthMeters: number;
|
||||||
maxLengthMeters: number | null;
|
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;
|
usedWeightTons: number;
|
||||||
maxWeightTons: number | null;
|
maxWeightTons: number | null;
|
||||||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||||||
maxWagons: number | null;
|
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: {
|
counts: {
|
||||||
allocated: number;
|
allocated: number;
|
||||||
@@ -1076,21 +1089,52 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||||
const room = budget.remainingFor(leg);
|
const room = budget.remainingFor(leg);
|
||||||
const byWagonType = allowed.map(({ wagonTypeId, dims }) => {
|
// The abstract budget can't tell wagon types apart — cap each type's free
|
||||||
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
|
// count with the PHYSICAL wagons of that type the train (or yard pool)
|
||||||
return {
|
// actually holds on this leg, and on a built train hide types the consist
|
||||||
wagonTypeId,
|
// doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free".
|
||||||
code: type?.code ?? null,
|
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||||
name: type?.name ?? null,
|
schedule.id,
|
||||||
freeWagons: this.bookableWithin(room, dims).wagons,
|
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(
|
const freeWagons = byWagonType.reduce(
|
||||||
(best, t) => Math.max(best, t.freeWagons),
|
(best, t) => Math.max(best, t.freeWagons),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const builtTrain = schedule.trainSet?.train;
|
||||||
out.push({
|
out.push({
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
|
trainNumber:
|
||||||
|
schedule.trainNumber ??
|
||||||
|
builtTrain?.exportTrainNumber ??
|
||||||
|
builtTrain?.trainNumber ??
|
||||||
|
null,
|
||||||
|
trainName: builtTrain?.trainName ?? builtTrain?.code ?? null,
|
||||||
departure: schedule.scheduledDepartureDate!,
|
departure: schedule.scheduledDepartureDate!,
|
||||||
bookingClosesAt: schedule.windowClosesAt ?? null,
|
bookingClosesAt: schedule.windowClosesAt ?? null,
|
||||||
isOpen: this.isFillable(schedule),
|
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
|
// 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.
|
// display-only candidates: they are excluded from the capacity meters below.
|
||||||
const pinnedIds = new Set(bookings.map((b) => b.id));
|
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 {
|
try {
|
||||||
const stops = await this.stopsForSchedule(s);
|
|
||||||
const candidates =
|
const candidates =
|
||||||
await this.bookingsRepository.findBatchPoolByCorridorDay(
|
await this.bookingsRepository.findBatchPoolByCorridorDay(
|
||||||
stops,
|
stops,
|
||||||
@@ -1662,6 +1715,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const windowBookings = items.filter((i) => i.fullyExecutedAt);
|
const windowBookings = items.filter((i) => i.fullyExecutedAt);
|
||||||
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
|
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
|
||||||
|
|
||||||
|
const stopLabels =
|
||||||
|
stops.length > 2 ? await this.yardLabels(stops) : new Map<string, string>();
|
||||||
|
const yardsByBookingId = new Map(
|
||||||
|
bookings.map((b) => [
|
||||||
|
b.id,
|
||||||
|
{
|
||||||
|
originYardId: b.originYardId ?? null,
|
||||||
|
destinationYardId: b.destinationYardId ?? null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduleId: s.id,
|
scheduleId: s.id,
|
||||||
scheduleReference: s.reference ?? null,
|
scheduleReference: s.reference ?? null,
|
||||||
@@ -1707,6 +1772,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
items.filter((i) => pinnedIds.has(i.id)),
|
items.filter((i) => pinnedIds.has(i.id)),
|
||||||
loco,
|
loco,
|
||||||
s.maxWagons ?? null,
|
s.maxWagons ?? null,
|
||||||
|
{
|
||||||
|
stops,
|
||||||
|
labelByYardId: stopLabels,
|
||||||
|
yardsByBookingId,
|
||||||
|
trainLengthMeters: this.builtTrainLengthOf(s),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
counts: {
|
counts: {
|
||||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||||
@@ -1747,6 +1818,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private computeBoardCapacity(
|
private computeBoardCapacity(
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
id: string;
|
||||||
state: BatchBoardBookingState;
|
state: BatchBoardBookingState;
|
||||||
wagons: number;
|
wagons: number;
|
||||||
weightTons: number;
|
weightTons: number;
|
||||||
@@ -1754,6 +1826,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}>,
|
}>,
|
||||||
loco: LocomotiveLimits | null,
|
loco: LocomotiveLimits | null,
|
||||||
maxWagons: number | null,
|
maxWagons: number | null,
|
||||||
|
legCtx?: {
|
||||||
|
/** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */
|
||||||
|
stops: string[];
|
||||||
|
labelByYardId: Map<string, string>;
|
||||||
|
yardsByBookingId: Map<
|
||||||
|
string,
|
||||||
|
{ originYardId: string | null; destinationYardId: string | null }
|
||||||
|
>;
|
||||||
|
trainLengthMeters: number | null;
|
||||||
|
},
|
||||||
): BatchBoardSchedule["capacity"] {
|
): BatchBoardSchedule["capacity"] {
|
||||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||||
// Every booking still targeting this train holds gross weight — including
|
// Every booking still targeting this train holds gross weight — including
|
||||||
@@ -1771,18 +1853,72 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: null;
|
: null;
|
||||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
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<number>(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 {
|
return {
|
||||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
||||||
allocatedLengthMeters: round2(
|
allocatedLengthMeters: round2(
|
||||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
||||||
),
|
),
|
||||||
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
||||||
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
|
usedWeightTons,
|
||||||
maxWeightTons: caps ? caps.maxWeightTons : null,
|
maxWeightTons: caps ? caps.maxWeightTons : null,
|
||||||
maxWagons: maxWagons ?? 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<Map<string, string>> {
|
||||||
|
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(
|
private buildScheduleSummary(
|
||||||
s: TrainSchedule,
|
s: TrainSchedule,
|
||||||
items: BatchBoardBooking[],
|
items: BatchBoardBooking[],
|
||||||
@@ -1829,7 +1965,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
: null,
|
: 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: {
|
counts: {
|
||||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||||
|
|||||||
@@ -4139,6 +4139,7 @@ export class TrainSchedulingService {
|
|||||||
fittingBookings,
|
fittingBookings,
|
||||||
dto.originStationId,
|
dto.originStationId,
|
||||||
dto.destinationStationId,
|
dto.destinationStationId,
|
||||||
|
stops,
|
||||||
);
|
);
|
||||||
|
|
||||||
violations.push(
|
violations.push(
|
||||||
@@ -4185,6 +4186,8 @@ export class TrainSchedulingService {
|
|||||||
wagonPlan,
|
wagonPlan,
|
||||||
containerPlacements,
|
containerPlacements,
|
||||||
placementRules,
|
placementRules,
|
||||||
|
legByBookingId,
|
||||||
|
Math.max(1, stops.length - 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
violations.push(
|
violations.push(
|
||||||
@@ -5011,6 +5014,7 @@ export class TrainSchedulingService {
|
|||||||
bookings: Booking[],
|
bookings: Booking[],
|
||||||
scheduleOriginYardId: string,
|
scheduleOriginYardId: string,
|
||||||
scheduleDestinationYardId: string,
|
scheduleDestinationYardId: string,
|
||||||
|
stops: string[],
|
||||||
): void {
|
): void {
|
||||||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||||||
for (const slot of wagonPlan) {
|
for (const slot of wagonPlan) {
|
||||||
@@ -5026,13 +5030,33 @@ export class TrainSchedulingService {
|
|||||||
b.originYardId === first.originYardId &&
|
b.originYardId === first.originYardId &&
|
||||||
b.destinationYardId === first.destinationYardId,
|
b.destinationYardId === first.destinationYardId,
|
||||||
);
|
);
|
||||||
if (!sameCorridor) continue;
|
if (sameCorridor) {
|
||||||
slot.boardYardId =
|
slot.boardYardId =
|
||||||
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
|
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
|
||||||
slot.alightYardId =
|
slot.alightYardId =
|
||||||
first.destinationYardId === scheduleDestinationYardId
|
first.destinationYardId === scheduleDestinationYardId
|
||||||
? null
|
? null
|
||||||
: first.destinationYardId;
|
: 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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
|||||||
new Map(entries);
|
new Map(entries);
|
||||||
|
|
||||||
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
|
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({
|
const result = planWagonsWithStock({
|
||||||
bookings: [
|
bookings: [
|
||||||
containerBooking('EXPORT-1', 1, 1),
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
@@ -233,16 +235,19 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
|||||||
'EXPORT-1',
|
'EXPORT-1',
|
||||||
'INTERCITY-1',
|
'INTERCITY-1',
|
||||||
]);
|
]);
|
||||||
// Two slots planned, but both drawn from the single physical wagon.
|
expect(result.plan).toHaveLength(1);
|
||||||
expect(result.plan).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
const result = planWagonsWithStock({
|
||||||
bookings: [
|
bookings: [fortyFooter, containerBooking('INTERCITY-1', 1, 1)],
|
||||||
containerBooking('EXPORT-1', 1, 1),
|
|
||||||
containerBooking('INTERCITY-1', 1, 1),
|
|
||||||
],
|
|
||||||
allowed,
|
allowed,
|
||||||
stock: {
|
stock: {
|
||||||
mode: 'TRAIN',
|
mode: 'TRAIN',
|
||||||
@@ -250,7 +255,6 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
|||||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
},
|
},
|
||||||
legs: legs([
|
legs: legs([
|
||||||
// Both ride edge 0 — they compete for the one wagon.
|
|
||||||
['EXPORT-1', { from: 0, to: 2 }],
|
['EXPORT-1', { from: 0, to: 2 }],
|
||||||
['INTERCITY-1', { from: 0, to: 1 }],
|
['INTERCITY-1', { from: 0, to: 1 }],
|
||||||
]),
|
]),
|
||||||
@@ -263,9 +267,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
|||||||
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
|
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', () => {
|
it('packs disjoint-leg 20fts onto one wagon instead of appending a second', () => {
|
||||||
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
|
// Two 20ft units, two wagons in stock — cross-leg TEU sharing still fills
|
||||||
// must open separate slots (each with its own leg), not one mixed slot.
|
// the open wagon (span grows to the union) rather than opening wagon #2.
|
||||||
const result = planWagonsWithStock({
|
const result = planWagonsWithStock({
|
||||||
bookings: [
|
bookings: [
|
||||||
containerBooking('EXPORT-1', 1, 1),
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
@@ -284,11 +288,12 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
|||||||
edgeCount: 2,
|
edgeCount: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.plan).toHaveLength(2);
|
expect(result.deferred).toHaveLength(0);
|
||||||
const bookingsPerSlot = result.plan.map((s) =>
|
expect(result.plan).toHaveLength(1);
|
||||||
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
|
const bookingsInSlot = [
|
||||||
);
|
...new Set(result.plan[0]!.allocations.map((a) => a.bookingId)),
|
||||||
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
|
].sort();
|
||||||
|
expect(bookingsInSlot).toEqual(['EXPORT-1', 'INTERCITY-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('behaves exactly like the whole-route planner when no legs are given', () => {
|
it('behaves exactly like the whole-route planner when no legs are given', () => {
|
||||||
|
|||||||
@@ -53,18 +53,25 @@ export type FlexPlanResult = {
|
|||||||
|
|
||||||
type OpenSlot = {
|
type OpenSlot = {
|
||||||
slot: WagonPlanSlot;
|
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: SlotLoadType;
|
||||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
freeCapacityTons: number;
|
freeCapacityTons: number;
|
||||||
/**
|
/**
|
||||||
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
|
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
|
||||||
* share a slot when their legs are identical — mixing corridors in one slot
|
* prefer a same-leg slot but may extend onto a different-leg one (span
|
||||||
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
|
* grows to the union); bulk still shares only on an identical leg.
|
||||||
* re-occupy edges the cargo never rides.
|
|
||||||
*/
|
*/
|
||||||
legKey: string;
|
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. */
|
/** 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;
|
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
||||||
const open: OpenSlot = {
|
const open: OpenSlot = {
|
||||||
slot: slotFromWagonType(chosen, kind),
|
slot: slotFromWagonType(chosen, kind),
|
||||||
teuUsed: 0,
|
teuPerEdge: new Array<number>(edgeCount).fill(0),
|
||||||
kind,
|
kind,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
freeCapacityTons: Number(chosen.capacityTons),
|
freeCapacityTons: Number(chosen.capacityTons),
|
||||||
legKey: legKeyOf(leg),
|
legKey: legKeyOf(leg),
|
||||||
|
covered: { ...leg },
|
||||||
};
|
};
|
||||||
openSlots.push(open);
|
openSlots.push(open);
|
||||||
return 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 tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||||
const leg = legFor(booking);
|
const leg = legFor(booking);
|
||||||
const legKey = legKeyOf(leg);
|
const legKey = legKeyOf(leg);
|
||||||
@@ -260,17 +305,23 @@ export function planWagonsWithStock(params: {
|
|||||||
}
|
}
|
||||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||||
let target = openSlots.find(
|
const fitsSlot = (open: OpenSlot): boolean =>
|
||||||
(open) =>
|
open.kind === 'CONTAINER' &&
|
||||||
open.kind === 'CONTAINER' &&
|
allowedIds.has(open.slot.wagonTypeId) &&
|
||||||
open.legKey === legKey &&
|
teuFits(open, leg, teu) &&
|
||||||
allowedIds.has(open.slot.wagonTypeId) &&
|
canExtendSpan(open, leg);
|
||||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
// 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) {
|
if (!target) {
|
||||||
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
||||||
if ('message' in openedSlot) return openedSlot;
|
if ('message' in openedSlot) return openedSlot;
|
||||||
target = openedSlot;
|
target = openedSlot;
|
||||||
|
} else {
|
||||||
|
extendSpan(target, leg);
|
||||||
}
|
}
|
||||||
addAllocation(
|
addAllocation(
|
||||||
target.slot,
|
target.slot,
|
||||||
@@ -279,7 +330,9 @@ export function planWagonsWithStock(params: {
|
|||||||
unit.grossWeightTons,
|
unit.grossWeightTons,
|
||||||
AllocationLoadType.Container,
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -343,7 +396,8 @@ export function planWagonsWithStock(params: {
|
|||||||
);
|
);
|
||||||
const slotCountSnapshot = openSlots.length;
|
const slotCountSnapshot = openSlots.length;
|
||||||
const slotStateSnapshot = openSlots.map((open) => ({
|
const slotStateSnapshot = openSlots.map((open) => ({
|
||||||
teuUsed: open.teuUsed,
|
teuPerEdge: [...open.teuPerEdge],
|
||||||
|
covered: { ...open.covered },
|
||||||
freeCapacityTons: open.freeCapacityTons,
|
freeCapacityTons: open.freeCapacityTons,
|
||||||
assignedWeightTons: open.slot.assignedWeightTons,
|
assignedWeightTons: open.slot.assignedWeightTons,
|
||||||
allocationCount: open.slot.allocations.length,
|
allocationCount: open.slot.allocations.length,
|
||||||
@@ -363,7 +417,8 @@ export function planWagonsWithStock(params: {
|
|||||||
openSlots.forEach((open, index) => {
|
openSlots.forEach((open, index) => {
|
||||||
const snap = slotStateSnapshot[index];
|
const snap = slotStateSnapshot[index];
|
||||||
if (!snap) return;
|
if (!snap) return;
|
||||||
open.teuUsed = snap.teuUsed;
|
open.teuPerEdge = [...snap.teuPerEdge];
|
||||||
|
open.covered = { ...snap.covered };
|
||||||
open.freeCapacityTons = snap.freeCapacityTons;
|
open.freeCapacityTons = snap.freeCapacityTons;
|
||||||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||||||
open.slot.allocations.length = snap.allocationCount;
|
open.slot.allocations.length = snap.allocationCount;
|
||||||
|
|||||||
@@ -665,6 +665,14 @@ export function validateContainerPlacements(
|
|||||||
wagonPlan: WagonPlanSlot[],
|
wagonPlan: WagonPlanSlot[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
rules?: ContainerPlacementRules,
|
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<string, { from: number; to: number }>,
|
||||||
|
edgeCount?: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const units = expandBookingContainerUnits(containerBookings);
|
const units = expandBookingContainerUnits(containerBookings);
|
||||||
@@ -721,8 +729,18 @@ export function validateContainerPlacements(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const slotTeuUsed = new Map<number, number>();
|
// TEU and weight are tracked PER EDGE of a unit's leg; without legs there is
|
||||||
const slotWeightUsed = new Map<number, number>();
|
// 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<number, number[]>();
|
||||||
|
const slotWeightUsed = new Map<number, number[]>();
|
||||||
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
|
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
|
||||||
|
|
||||||
for (const placement of placements) {
|
for (const placement of placements) {
|
||||||
@@ -734,22 +752,38 @@ export function validateContainerPlacements(
|
|||||||
if (!unit) continue;
|
if (!unit) continue;
|
||||||
|
|
||||||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||||
const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0;
|
const leg = legOf(unit.bookingId);
|
||||||
if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
const teuRow =
|
||||||
|
slotTeuUsed.get(placement.sequenceNo) ?? new Array<number>(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(
|
violations.push(
|
||||||
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
|
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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);
|
const slot = slotBySeq.get(placement.sequenceNo);
|
||||||
if (slot) {
|
if (slot) {
|
||||||
const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons;
|
const weightRow =
|
||||||
slotWeightUsed.set(placement.sequenceNo, weight);
|
slotWeightUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
|
||||||
if (weight > slot.capacityTons) {
|
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(
|
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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -739,6 +739,7 @@ export function AllocateBookingWizard({
|
|||||||
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
|
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
|
||||||
<TrainCompositionDiagram
|
<TrainCompositionDiagram
|
||||||
locomotive={assignedSchedule?.trainSet?.locomotive}
|
locomotive={assignedSchedule?.trainSet?.locomotive}
|
||||||
|
locomotives={assignedSchedule?.trainSet?.locomotives}
|
||||||
wagons={
|
wagons={
|
||||||
assignedSchedule?.trainSet?.wagons?.length
|
assignedSchedule?.trainSet?.wagons?.length
|
||||||
? assignedSchedule.trainSet.wagons
|
? assignedSchedule.trainSet.wagons
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useMemo, useState } from "react";
|
import { memo, useMemo, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
@@ -248,6 +248,96 @@ function RankedCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive-run grouping of an already-ranked lane by boarding class:
|
||||||
|
* government first, then each booking-window cycle (windowCycleNo is 0-based,
|
||||||
|
* so cycle 0 renders as "1st cycle window"). rankBookings sorts gov → cycle
|
||||||
|
* asc, so consecutive runs are exactly the cycle groups.
|
||||||
|
*/
|
||||||
|
type CycleGroup = {
|
||||||
|
key: string;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
sub: string | null;
|
||||||
|
items: BatchBoardBookingDetail[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CYCLE_COLORS = ["indigo", "cyan", "teal"];
|
||||||
|
|
||||||
|
const ordinal = (n: number) =>
|
||||||
|
n === 1 ? "1st" : n === 2 ? "2nd" : n === 3 ? "3rd" : `${n}th`;
|
||||||
|
|
||||||
|
function groupMeta(b: BatchBoardBookingDetail): Omit<CycleGroup, "items"> {
|
||||||
|
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 (
|
||||||
|
<Paper
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
borderColor: cardVar(group.color, 2),
|
||||||
|
background: cardVar(group.color, 0),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={8} mb={8} wrap="nowrap">
|
||||||
|
<ThemeIcon size="sm" radius="xl" variant="light" color={group.color}>
|
||||||
|
{group.key === "gov" ? <Crown size={12} /> : <Layers size={12} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" fw={700} c={`${group.color}.8`}>
|
||||||
|
{group.label}
|
||||||
|
</Text>
|
||||||
|
{group.sub ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
— {group.sub}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text size="xs" fw={600} c="dimmed" ml="auto" style={{ flexShrink: 0 }}>
|
||||||
|
{group.items.length} booking{group.items.length === 1 ? "" : "s"} ·{" "}
|
||||||
|
{wagons}w
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>{children}</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The capacity cut line drawn between "in the batch" and "waiting list". */
|
/** The capacity cut line drawn between "in the batch" and "waiting list". */
|
||||||
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
|
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
|
||||||
const full = max != null && used >= max;
|
const full = max != null && used >= max;
|
||||||
@@ -482,19 +572,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{lanes.inBatch.map((b) => {
|
{groupByCycle(lanes.inBatch).map((g) => (
|
||||||
rankNo += 1;
|
<CycleSection key={g.key} group={g}>
|
||||||
return (
|
{g.items.map((b) => {
|
||||||
<RankedCard
|
rankNo += 1;
|
||||||
key={b.id}
|
return (
|
||||||
rank={rankNo}
|
<RankedCard
|
||||||
booking={b}
|
key={b.id}
|
||||||
scoreMax={scoreMax}
|
rank={rankNo}
|
||||||
phase={phase}
|
booking={b}
|
||||||
isPayPhase={isPayPhase}
|
scoreMax={scoreMax}
|
||||||
/>
|
phase={phase}
|
||||||
);
|
isPayPhase={isPayPhase}
|
||||||
})}
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CycleSection>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -514,19 +608,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{lanes.waiting.map((b) => {
|
{groupByCycle(lanes.waiting).map((g) => (
|
||||||
rankNo += 1;
|
<CycleSection key={g.key} group={g}>
|
||||||
return (
|
{g.items.map((b) => {
|
||||||
<RankedCard
|
rankNo += 1;
|
||||||
key={b.id}
|
return (
|
||||||
rank={rankNo}
|
<RankedCard
|
||||||
booking={b}
|
key={b.id}
|
||||||
scoreMax={scoreMax}
|
rank={rankNo}
|
||||||
phase={phase}
|
booking={b}
|
||||||
isPayPhase={isPayPhase}
|
scoreMax={scoreMax}
|
||||||
/>
|
phase={phase}
|
||||||
);
|
isPayPhase={isPayPhase}
|
||||||
})}
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CycleSection>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -288,8 +288,11 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
|||||||
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
|
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
// container blocks: one per container number, up to 4 — cross-leg TEU
|
||||||
const blocks = wagon.containerNumbers.slice(0, 2);
|
// 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 (
|
return (
|
||||||
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
|
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
|
||||||
@@ -376,15 +379,21 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Group gap={4} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
<Box
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: blocks.length > 1 ? "1fr 1fr" : "1fr",
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||||||
<Box
|
<Box
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
height: 28,
|
height: twoRows ? 14 : 28,
|
||||||
borderRadius: 5,
|
borderRadius: twoRows ? 4 : 5,
|
||||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.15)",
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.15)",
|
||||||
@@ -395,23 +404,25 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
|||||||
padding: "0 3px",
|
padding: "0 3px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* corrugation lines */}
|
{/* corrugation lines — dropped in two-row mode, no room */}
|
||||||
<Box
|
{!twoRows ? (
|
||||||
style={{
|
<Box
|
||||||
width: "80%",
|
style={{
|
||||||
height: 2,
|
width: "80%",
|
||||||
marginBottom: 2,
|
height: 2,
|
||||||
background:
|
marginBottom: 2,
|
||||||
"repeating-linear-gradient(90deg, rgba(255,255,255,0.4) 0 3px, transparent 3px 6px)",
|
background:
|
||||||
borderRadius: 1,
|
"repeating-linear-gradient(90deg, rgba(255,255,255,0.4) 0 3px, transparent 3px 6px)",
|
||||||
}}
|
borderRadius: 1,
|
||||||
/>
|
}}
|
||||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
/>
|
||||||
|
) : null}
|
||||||
|
<Text size={twoRows ? "7px" : "8px"} fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||||
{cn}
|
{cn}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type DragState = { sourceWagonId: string } | null;
|
|||||||
interface InteractiveTrainConsistProps {
|
interface InteractiveTrainConsistProps {
|
||||||
wagons: Wagon[];
|
wagons: Wagon[];
|
||||||
locomotive: Locomotive | null | undefined;
|
locomotive: Locomotive | null | undefined;
|
||||||
|
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
|
||||||
|
locomotives?: NonNullable<TrainScheduleDetail["trainSet"]>["locomotives"] | null;
|
||||||
/** Resolve the customer/company name for a booking id (joined from schedule bookings). */
|
/** Resolve the customer/company name for a booking id (joined from schedule bookings). */
|
||||||
getCompany: (bookingId: string | undefined) => string | null;
|
getCompany: (bookingId: string | undefined) => string | null;
|
||||||
selectedWagonId: string | null;
|
selectedWagonId: string | null;
|
||||||
@@ -557,6 +559,7 @@ function WagonCar({
|
|||||||
export const InteractiveTrainConsist = ({
|
export const InteractiveTrainConsist = ({
|
||||||
wagons,
|
wagons,
|
||||||
locomotive,
|
locomotive,
|
||||||
|
locomotives,
|
||||||
getCompany,
|
getCompany,
|
||||||
selectedWagonId,
|
selectedWagonId,
|
||||||
onSelectWagon,
|
onSelectWagon,
|
||||||
@@ -565,6 +568,7 @@ export const InteractiveTrainConsist = ({
|
|||||||
onMoveLoad,
|
onMoveLoad,
|
||||||
}: InteractiveTrainConsistProps) => {
|
}: InteractiveTrainConsistProps) => {
|
||||||
const [drag, setDrag] = useState<DragState>(null);
|
const [drag, setDrag] = useState<DragState>(null);
|
||||||
|
const locos = locomotives?.length ? locomotives : locomotive ? [locomotive] : [];
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -589,7 +593,12 @@ export const InteractiveTrainConsist = ({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
|
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
|
||||||
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
|
{locos.map((loco, i) => (
|
||||||
|
<Group key={loco.code ?? i} gap={0} wrap="nowrap" align="flex-start">
|
||||||
|
{i > 0 ? <Coupler /> : null}
|
||||||
|
<LocomotiveCar locomotive={loco} />
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
{wagons.length === 0 ? (
|
{wagons.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed" pl="md" pt="lg">
|
<Text size="sm" c="dimmed" pl="md" pt="lg">
|
||||||
No wagons assigned
|
No wagons assigned
|
||||||
@@ -599,7 +608,7 @@ export const InteractiveTrainConsist = ({
|
|||||||
const bookingId = wagon.allocations?.[0]?.bookingId;
|
const bookingId = wagon.allocations?.[0]?.bookingId;
|
||||||
return (
|
return (
|
||||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
|
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
|
||||||
{i > 0 || locomotive ? <Coupler /> : null}
|
{i > 0 || locos.length ? <Coupler /> : null}
|
||||||
<WagonCar
|
<WagonCar
|
||||||
wagon={wagon}
|
wagon={wagon}
|
||||||
company={getCompany(bookingId)}
|
company={getCompany(bookingId)}
|
||||||
|
|||||||
@@ -135,13 +135,27 @@ export const TrainConsistView = ({
|
|||||||
const weightUsed = cargoUsed + tareUsed;
|
const weightUsed = cargoUsed + tareUsed;
|
||||||
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
|
const lengthUsed = wagons.reduce((sum, w) => 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 (
|
return (
|
||||||
<Stack gap="md" style={{ width: "100%" }}>
|
<Stack gap="md" style={{ width: "100%" }}>
|
||||||
<TrainStatsBar
|
<TrainStatsBar
|
||||||
weightUsed={weightUsed}
|
weightUsed={weightUsed}
|
||||||
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
|
weightMax={weightMax}
|
||||||
lengthUsed={lengthUsed}
|
lengthUsed={lengthUsed}
|
||||||
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
lengthMax={lengthMax}
|
||||||
wagonCount={loadedCount}
|
wagonCount={loadedCount}
|
||||||
wagonMax={maxWagons}
|
wagonMax={maxWagons}
|
||||||
/>
|
/>
|
||||||
@@ -202,6 +216,7 @@ export const TrainConsistView = ({
|
|||||||
<InteractiveTrainConsist
|
<InteractiveTrainConsist
|
||||||
wagons={wagons}
|
wagons={wagons}
|
||||||
locomotive={trainSet?.locomotive}
|
locomotive={trainSet?.locomotive}
|
||||||
|
locomotives={trainSet?.locomotives}
|
||||||
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
|
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
|
||||||
selectedWagonId={selectedWagonId}
|
selectedWagonId={selectedWagonId}
|
||||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
@@ -923,9 +924,19 @@ export default function BatchScheduleDetailPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Train length",
|
label: "Train length",
|
||||||
value: data.capacity.maxLengthMeters
|
// A built train's length is its marshalled consist — always
|
||||||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
// the same figure the Train Builder shows.
|
||||||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
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,
|
icon: Ruler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -933,9 +944,24 @@ export default function BatchScheduleDetailPage() {
|
|||||||
value: data.capacity.maxWeightTons
|
value: data.capacity.maxWeightTons
|
||||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||||
: fmtTons(data.capacity.usedWeightTons),
|
: 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,
|
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",
|
label: "Bookings",
|
||||||
value: totalBookings,
|
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 ? (
|
||||||
|
<Paper
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
p="lg"
|
||||||
|
mt="lg"
|
||||||
|
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Group gap={8} mb="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
|
||||||
|
<Weight size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>Load per leg</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Each leg carries only the bookings riding it — the heaviest
|
||||||
|
leg is what the locomotive actually pulls.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" align="stretch" wrap="wrap">
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<Paper
|
||||||
|
key={i}
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 210,
|
||||||
|
borderColor: isPeak
|
||||||
|
? "var(--mantine-color-yellow-4)"
|
||||||
|
: "var(--mantine-color-gray-2)",
|
||||||
|
background: isPeak
|
||||||
|
? "var(--mantine-color-yellow-0)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" mb={6}>
|
||||||
|
<Text size="sm" fw={700} truncate>
|
||||||
|
{leg.from} → {leg.to}
|
||||||
|
</Text>
|
||||||
|
{isPeak ? (
|
||||||
|
<Badge size="xs" color="yellow" variant="filled">
|
||||||
|
peak
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={800} c={over ? "red.7" : "dark.5"}>
|
||||||
|
{fmtTons(leg.usedWeightTons)}
|
||||||
|
{max ? ` / ${fmtTons(max)}` : ""}
|
||||||
|
{pct != null ? ` · ${pct}%` : ""}
|
||||||
|
</Text>
|
||||||
|
{pct != null ? (
|
||||||
|
<Progress
|
||||||
|
mt={6}
|
||||||
|
value={Math.min(100, pct)}
|
||||||
|
color={over ? "red" : pct > 90 ? "yellow" : "edr-green"}
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
striped={over}
|
||||||
|
animated={over}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Booking pipeline */}
|
{/* Booking pipeline */}
|
||||||
<Paper
|
<Paper
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -1133,6 +1245,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
<TrainCompositionDiagram
|
<TrainCompositionDiagram
|
||||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||||
|
locomotives={scheduleDetailQuery.data.trainSet?.locomotives}
|
||||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||||
freightType={scheduleDetailQuery.data.freightType ?? null}
|
freightType={scheduleDetailQuery.data.freightType ?? null}
|
||||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
|
Ruler,
|
||||||
Send,
|
Send,
|
||||||
Train,
|
Train,
|
||||||
Weight,
|
Weight,
|
||||||
@@ -1076,6 +1077,17 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
: "No locomotives assigned",
|
: "No locomotives assigned",
|
||||||
icon: Train,
|
icon: Train,
|
||||||
},
|
},
|
||||||
|
...(schedule.trainSet?.totalLengthMeters
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Train length",
|
||||||
|
// Physical consist length — the same figure Train Builder shows.
|
||||||
|
value: `${schedule.trainSet.totalLengthMeters}m`,
|
||||||
|
hint: "built consist — matches Train Builder",
|
||||||
|
icon: Ruler,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: "Bookings",
|
label: "Bookings",
|
||||||
value: schedule.bookings?.length ?? 0,
|
value: schedule.bookings?.length ?? 0,
|
||||||
|
|||||||
@@ -363,12 +363,17 @@ export interface BatchBoardSchedule {
|
|||||||
allocatedLengthMeters: number;
|
allocatedLengthMeters: number;
|
||||||
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
|
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
|
||||||
maxLengthMeters: number | null;
|
maxLengthMeters: number | null;
|
||||||
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */
|
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both.
|
||||||
|
* On a multi-stop corridor this is the HEAVIEST single edge, not the sum. */
|
||||||
usedWeightTons: number;
|
usedWeightTons: number;
|
||||||
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
|
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
|
||||||
maxWeightTons: number | null;
|
maxWeightTons: number | null;
|
||||||
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
|
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
|
||||||
maxWagons: number | null;
|
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: {
|
counts: {
|
||||||
allocated: number;
|
allocated: number;
|
||||||
|
|||||||
@@ -1108,6 +1108,10 @@ export interface ExportTrainOptionWagonType {
|
|||||||
*/
|
*/
|
||||||
export interface ExportTrainOption {
|
export interface ExportTrainOption {
|
||||||
scheduleId: string;
|
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;
|
departure: string;
|
||||||
bookingClosesAt: string | null;
|
bookingClosesAt: string | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|||||||
@@ -89,7 +89,14 @@ export function ExportTrainPicker({
|
|||||||
<TrainFront size={16} color={selected ? SELECTED : "#5B6B7A"} />
|
<TrainFront size={16} color={selected ? SELECTED : "#5B6B7A"} />
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="13px" fw={600} c="#10202F">
|
<Text fz="13px" fw={600} c="#10202F">
|
||||||
Departs {departureLabel(option.departure)} EAT
|
{option.trainNumber
|
||||||
|
? `Train ${option.trainNumber}`
|
||||||
|
: (option.trainName ?? "Train")}
|
||||||
|
{option.trainNumber && option.trainName
|
||||||
|
? ` · ${option.trainName}`
|
||||||
|
: ""}
|
||||||
|
{" — departs "}
|
||||||
|
{departureLabel(option.departure)} EAT
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="12px" c="dimmed">
|
<Text fz="12px" c="dimmed">
|
||||||
{option.freeWagons} wagon{option.freeWagons === 1 ? "" : "s"} free
|
{option.freeWagons} wagon{option.freeWagons === 1 ? "" : "s"} free
|
||||||
|
|||||||
Reference in New Issue
Block a user