mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
190 lines
6.5 KiB
TypeScript
190 lines
6.5 KiB
TypeScript
import type {
|
|
BatchBoardBookingDetail,
|
|
BatchBoardScheduleDetail,
|
|
} from "@/types/trainScheduling";
|
|
|
|
/**
|
|
* Client-side forecast of what the batch engine WOULD select if it ran right now.
|
|
*
|
|
* The real selection only happens once the document-review window closes and staff
|
|
* hit "run batch". Before that, operations can only see the *current* per-booking
|
|
* state (READY / SELECTED / …). This module simulates the engine's greedy fill so
|
|
* the board can show the likely winners + waiting list live, during OPEN and
|
|
* DOC_REVIEW, before anything is committed.
|
|
*
|
|
* It mirrors the engine (booking-batch.service): rank government-first, then
|
|
* priority score desc, then oldest booked; greedily board each booking while it
|
|
* fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and
|
|
* train length (metres). The first booking that busts any axis, and everyone after
|
|
* it, drops to the waiting list. Purely a projection; the server stays the source
|
|
* of truth for the real run.
|
|
*/
|
|
|
|
export interface ForecastLimits {
|
|
/** Wagon-slot cap (schedule.maxWagons), or null if unknown. */
|
|
maxWagons: number | null;
|
|
/** Locomotive max pull weight in tons, or null. */
|
|
maxWeightTons: number | null;
|
|
/** Max train length in metres, or null. */
|
|
maxLengthMeters: number | null;
|
|
}
|
|
|
|
/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */
|
|
export type BlockingAxis = "wagons" | "weight" | "length";
|
|
|
|
export interface ForecastRow {
|
|
booking: BatchBoardBookingDetail;
|
|
/** 1-based rank across the whole eligible pool. */
|
|
rank: number;
|
|
/** True → boards in the simulated batch; false → simulated waiting list. */
|
|
selected: boolean;
|
|
/** Cumulative wagons/weight/length AFTER this booking (only when selected). */
|
|
cumulativeWagons: number;
|
|
cumulativeWeightTons: number;
|
|
cumulativeLengthMeters: number;
|
|
/** If not selected, the first axis that would have overflowed. */
|
|
blockedBy: BlockingAxis | null;
|
|
}
|
|
|
|
export interface ForecastResult {
|
|
rows: ForecastRow[];
|
|
selected: ForecastRow[];
|
|
waiting: ForecastRow[];
|
|
/** Bookings excluded from the sim entirely (expired / no signed contract). */
|
|
ineligible: BatchBoardBookingDetail[];
|
|
limits: ForecastLimits;
|
|
/** Totals of the simulated batch. */
|
|
usedWagons: number;
|
|
usedWeightTons: number;
|
|
usedLengthMeters: number;
|
|
/** True once any axis is at/over its cap — train is "full" in the sim. */
|
|
full: boolean;
|
|
}
|
|
|
|
/** Engine rank order: government first, then priority desc, then oldest booked. */
|
|
export function rankBookings(
|
|
bookings: BatchBoardBookingDetail[],
|
|
): BatchBoardBookingDetail[] {
|
|
const time = (b: BatchBoardBookingDetail) =>
|
|
b.fullyExecutedAt
|
|
? new Date(b.fullyExecutedAt).getTime()
|
|
: Number.MAX_SAFE_INTEGER;
|
|
return [...bookings].sort((a, b) => {
|
|
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
|
if (b.priorityScore !== a.priorityScore)
|
|
return b.priorityScore - a.priorityScore;
|
|
return time(a) - time(b);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A booking can compete in the batch only once its contract is signed. Expired
|
|
* bookings and pending-contract bookings never board, so they're pulled out of the
|
|
* sim (surfaced separately so they don't vanish from the board).
|
|
*/
|
|
function isEligible(b: BatchBoardBookingDetail): boolean {
|
|
return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT";
|
|
}
|
|
|
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
|
|
/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */
|
|
function overflows(used: number, add: number, cap: number | null): boolean {
|
|
return cap != null && used + add > cap;
|
|
}
|
|
|
|
export function simulateBatch(
|
|
bookings: BatchBoardBookingDetail[],
|
|
limits: ForecastLimits,
|
|
): ForecastResult {
|
|
const ranked = rankBookings(bookings);
|
|
const eligible = ranked.filter(isEligible);
|
|
const ineligible = ranked.filter((b) => !isEligible(b));
|
|
|
|
const rows: ForecastRow[] = [];
|
|
let wagons = 0;
|
|
let weight = 0;
|
|
let length = 0;
|
|
// Once the train is full we stop boarding, but keep ranking the rest as waiting.
|
|
let full = false;
|
|
|
|
eligible.forEach((booking, i) => {
|
|
let blockedBy: BlockingAxis | null = null;
|
|
if (!full) {
|
|
if (overflows(wagons, booking.wagons, limits.maxWagons))
|
|
blockedBy = "wagons";
|
|
else if (overflows(weight, booking.weightTons, limits.maxWeightTons))
|
|
blockedBy = "weight";
|
|
else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters))
|
|
blockedBy = "length";
|
|
}
|
|
// Strict fill: the first booking that doesn't fit closes the train, so lower-
|
|
// priority bookings can't leapfrog it even if they'd individually fit. Matches
|
|
// the engine's greedy pass.
|
|
const selected = !full && blockedBy === null;
|
|
if (selected) {
|
|
wagons += booking.wagons;
|
|
weight = round2(weight + booking.weightTons);
|
|
length = round2(length + booking.lengthMeters);
|
|
} else {
|
|
full = true;
|
|
}
|
|
rows.push({
|
|
booking,
|
|
rank: i + 1,
|
|
selected,
|
|
cumulativeWagons: selected ? wagons : 0,
|
|
cumulativeWeightTons: selected ? weight : 0,
|
|
cumulativeLengthMeters: selected ? length : 0,
|
|
blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)),
|
|
});
|
|
});
|
|
|
|
return {
|
|
rows,
|
|
selected: rows.filter((r) => r.selected),
|
|
waiting: rows.filter((r) => !r.selected),
|
|
ineligible,
|
|
limits,
|
|
usedWagons: wagons,
|
|
usedWeightTons: weight,
|
|
usedLengthMeters: length,
|
|
full,
|
|
};
|
|
}
|
|
|
|
/** When the train closed on an earlier booking, name the tightest axis for the hint. */
|
|
function firstBindingAxis(limits: ForecastLimits): BlockingAxis {
|
|
if (limits.maxWagons != null) return "wagons";
|
|
if (limits.maxWeightTons != null) return "weight";
|
|
return "length";
|
|
}
|
|
|
|
/** Pull the three capacity caps off the board detail response. */
|
|
export function limitsFromDetail(
|
|
data: BatchBoardScheduleDetail,
|
|
): ForecastLimits {
|
|
return {
|
|
maxWagons: data.capacity.maxWagons ?? null,
|
|
maxWeightTons:
|
|
data.capacity.maxWeightTons ??
|
|
data.locomotive?.maxPullWeightTons ??
|
|
null,
|
|
maxLengthMeters:
|
|
data.capacity.maxLengthMeters ??
|
|
data.locomotive?.maxTrainLengthMeters ??
|
|
null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The forecast is meaningful before the batch is committed — i.e. while bookings
|
|
* are still being taken or reviewed. Once the engine has run (PAYMENT onward) the
|
|
* real per-booking state is the truth, so we stop showing the projection.
|
|
*/
|
|
export function forecastIsLive(
|
|
phase: BatchBoardScheduleDetail["windowPhase"],
|
|
): boolean {
|
|
return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW";
|
|
}
|