booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { compareSchedulingPriority } from "./compareSchedulingPriority";
describe("compareSchedulingPriority", () => {
it("ranks government above commercial regardless of date gap", () => {
const gov = {
isGovernment: true,
priorityScore: 100,
scheduledDate: "2026-06-25T08:00:00.000Z",
};
const commercial = {
isGovernment: false,
priorityScore: 50000,
scheduledDate: "2026-06-20T08:00:00.000Z",
};
expect(compareSchedulingPriority(gov, commercial)).toBeLessThan(0);
});
it("sorts by priority score within same tier", () => {
const high = { priorityScore: 100, scheduledDate: "2026-06-20T08:00:00.000Z" };
const low = { priorityScore: 10, scheduledDate: "2026-06-20T08:00:00.000Z" };
expect(compareSchedulingPriority(high, low)).toBeLessThan(0);
});
});

View File

@@ -0,0 +1,19 @@
export interface SchedulingPriorityRow {
isGovernment?: boolean;
priorityScore?: number;
scheduledDate: string;
}
/** Government first, then priority score, then earliest scheduled date. */
export function compareSchedulingPriority(
a: SchedulingPriorityRow,
b: SchedulingPriorityRow,
): number {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff;
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { BookingListRow } from "@/types/booking";
import { groupBookingsByThreeHourWindow } from "./groupBookingsByThreeHourWindow";
function row(
id: string,
scheduledDate: string,
priorityScore: number,
): BookingListRow {
return {
id,
reference: id,
customerLabel: "Customer",
status: "PAID",
scheduledDate,
totalAmount: 1000,
paymentCurrency: "ETB",
paymentStatus: "PAID",
tradeDirection: "IMPORT",
freightType: "CONTAINER",
originLabel: "Djibouti",
destinationLabel: "Addis",
priorityScore,
createdAt: scheduledDate,
};
}
describe("groupBookingsByThreeHourWindow", () => {
it("buckets bookings into UTC 3-hour windows", () => {
const buckets = groupBookingsByThreeHourWindow([
row("a", "2026-06-20T05:30:00.000Z", 10),
row("b", "2026-06-20T05:45:00.000Z", 20),
row("c", "2026-06-20T08:00:00.000Z", 30),
]);
expect(buckets).toHaveLength(2);
expect(buckets[0]?.bookings.map((b) => b.id)).toEqual(["b", "a"]);
expect(buckets[1]?.bookings.map((b) => b.id)).toEqual(["c"]);
expect(buckets[0]?.label).toContain("03:00");
expect(buckets[1]?.label).toContain("06:00");
});
it("places midnight boundary bookings in the correct bucket", () => {
const buckets = groupBookingsByThreeHourWindow([
row("late", "2026-06-20T23:45:00.000Z", 5),
row("early", "2026-06-21T00:15:00.000Z", 15),
]);
expect(buckets).toHaveLength(2);
expect(buckets[0]?.bookings[0]?.id).toBe("late");
expect(buckets[1]?.bookings[0]?.id).toBe("early");
});
it("sorts within each bucket by priorityScore descending", () => {
const buckets = groupBookingsByThreeHourWindow([
row("low", "2026-06-20T06:00:00.000Z", 5),
row("high", "2026-06-20T06:30:00.000Z", 50),
row("mid", "2026-06-20T07:00:00.000Z", 25),
]);
expect(buckets[0]?.bookings.map((b) => b.id)).toEqual(["high", "mid", "low"]);
});
});

View File

@@ -0,0 +1,91 @@
import { compareSchedulingPriority } from "./compareSchedulingPriority";
export interface ThreeHourSchedulable {
id: string;
priorityScore?: number;
scheduledDate?: string;
preferredDepartureDate?: string;
isGovernment?: boolean;
}
export interface ThreeHourBookingBucket<T extends ThreeHourSchedulable = ThreeHourSchedulable> {
key: string;
label: string;
start: Date;
end: Date;
bookings: T[];
}
function bucketStart(date: Date): Date {
const start = new Date(date);
start.setUTCMinutes(0, 0, 0);
const hour = start.getUTCHours();
start.setUTCHours(Math.floor(hour / 3) * 3);
return start;
}
function formatBucketLabel(start: Date, end: Date): string {
const dateFmt = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const timeFmt = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "UTC",
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} ${timeFmt.format(end)} UTC`;
}
function getScheduledDate(booking: ThreeHourSchedulable): Date {
const raw = booking.scheduledDate ?? booking.preferredDepartureDate;
if (!raw) return new Date(0);
return new Date(raw);
}
function toPriorityRow(booking: ThreeHourSchedulable) {
return {
isGovernment: booking.isGovernment,
priorityScore: booking.priorityScore,
scheduledDate: booking.scheduledDate ?? booking.preferredDepartureDate ?? "",
};
}
export function groupBookingsByThreeHourWindow<T extends ThreeHourSchedulable>(
bookings: T[],
): ThreeHourBookingBucket<T>[] {
const map = new Map<string, ThreeHourBookingBucket<T>>();
for (const booking of bookings) {
const scheduled = getScheduledDate(booking);
const start = bucketStart(scheduled);
const end = new Date(start);
end.setUTCHours(end.getUTCHours() + 3);
const key = start.toISOString();
const existing = map.get(key);
if (existing) {
existing.bookings.push(booking);
} else {
map.set(key, {
key,
label: formatBucketLabel(start, end),
start,
end,
bookings: [booking],
});
}
}
return [...map.values()]
.map((bucket) => ({
...bucket,
bookings: [...bucket.bookings].sort((a, b) =>
compareSchedulingPriority(toPriorityRow(a), toPriorityRow(b)),
),
}))
.sort((a, b) => a.start.getTime() - b.start.getTime());
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { BookingListRow } from "@/types/booking";
import { groupBookingsForOperationsQueue } from "./groupBookingsForOperationsQueue";
function row(
id: string,
opts: Partial<BookingListRow> = {},
): BookingListRow {
return {
id,
reference: id,
customerLabel: "Customer",
status: "PAID",
scheduledDate: opts.scheduledDate ?? "2026-06-20T08:00:00.000Z",
totalAmount: 1000,
paymentCurrency: "ETB",
paymentStatus: "PAID",
tradeDirection: "IMPORT",
freightType: "CONTAINER",
originLabel: "A",
destinationLabel: "B",
priorityScore: opts.priorityScore ?? 10,
createdAt: "2026-06-01T00:00:00.000Z",
...opts,
};
}
describe("groupBookingsForOperationsQueue", () => {
it("separates government from commercial 3-hour buckets", () => {
const result = groupBookingsForOperationsQueue([
row("c1", { isGovernment: false }),
row("g1", { isGovernment: true, governmentInstitution: "Ministry" }),
]);
expect(result.government).toHaveLength(1);
expect(result.government[0]?.id).toBe("g1");
expect(result.commercial).toHaveLength(1);
expect(result.commercial[0]?.bookings[0]?.id).toBe("c1");
});
});

View File

@@ -0,0 +1,23 @@
import type { BookingListRow } from "@/types/booking";
import { compareSchedulingPriority } from "./compareSchedulingPriority";
import {
groupBookingsByThreeHourWindow,
type ThreeHourBookingBucket,
} from "./groupBookingsByThreeHourWindow";
export interface OperationsQueueGroups {
government: BookingListRow[];
commercial: ThreeHourBookingBucket[];
}
export function groupBookingsForOperationsQueue(
bookings: BookingListRow[],
): OperationsQueueGroups {
const government = bookings
.filter((b) => b.isGovernment)
.sort(compareSchedulingPriority);
const commercial = groupBookingsByThreeHourWindow(
bookings.filter((b) => !b.isGovernment),
);
return { government, commercial };
}

View File

@@ -0,0 +1,33 @@
import { Freight } from "@edr/types";
import type { Wagon } from "@/services/wagon.service";
export function wagonMatchesScheduleDirection(
wagon: Pick<Wagon, "status" | "readiness">,
scheduleDirection?: string | null,
options?: { allowPinned?: boolean },
): boolean {
if (options?.allowPinned) return true;
if (wagon.status !== Freight.WagonStatus.Available) return false;
if (!scheduleDirection || scheduleDirection === "DOMESTIC") return true;
if (scheduleDirection === "IMPORT") {
return wagon.readiness === Freight.WagonReadiness.ImportReady;
}
if (scheduleDirection === "EXPORT") {
return wagon.readiness === Freight.WagonReadiness.ExportReady;
}
return true;
}
export function filterWagonsForSchedule(
wagons: Wagon[],
scheduleDirection?: string | null,
pinnedWagonIds?: Set<string>,
): Wagon[] {
return wagons.filter((wagon) => {
const isPinned = pinnedWagonIds?.has(wagon.id) ?? false;
return wagonMatchesScheduleDirection(wagon, scheduleDirection, {
allowPinned: isPinned,
});
});
}