mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user