feat(GRO-2156): travel buffer + reorder endpoint (Phase 2.2) (#180)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Build & Push Docker Images (pull_request) Successful in 27s

This commit was merged in pull request #180.
This commit is contained in:
2026-06-08 18:07:54 +00:00
parent 29c42e3130
commit ca62fb8ef6
4 changed files with 455 additions and 4 deletions
+151
View File
@@ -4,6 +4,8 @@ import {
estimateLeg,
nearestNeighborOrder,
optimizeRoute,
detectScheduleConflicts,
recomputeLegsForOrder,
MAX_STOPS_PER_ROUTE,
type RouteStopInput,
} from "../services/routeOptimization.js";
@@ -182,3 +184,152 @@ describe("optimizeRoute — >25 stop chunking", () => {
expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length);
});
});
describe("detectScheduleConflicts", () => {
const at = (iso: string) => new Date(iso);
it("returns no conflict and null gaps for an empty or single-stop route", () => {
expect(detectScheduleConflicts([])).toEqual([]);
const one = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 15,
},
]);
expect(one).toEqual([
{
hasConflict: false,
scheduleGapMins: null,
requiredGapMins: null,
shortfallMins: null,
},
]);
});
it("flags a tight schedule when gap < travel + buffer", () => {
// Stop 1 ends 10:00, stop 2 starts 10:20 → 20min gap. Travel 15 + buffer 15
// = 30 required → shortfall 10 → conflict.
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T10:20:00Z"),
appointmentEndTime: at("2026-06-08T11:00:00Z"),
travelMinsFromPrev: 15,
bufferMins: 15,
},
]);
expect(flags[0]!.hasConflict).toBe(false);
expect(flags[1]).toEqual({
hasConflict: true,
scheduleGapMins: 20,
requiredGapMins: 30,
shortfallMins: 10,
});
});
it("does not flag when the gap comfortably covers travel + buffer", () => {
// 90min gap, 15 travel + 15 buffer = 30 required → 60 slack → no conflict.
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T11:30:00Z"),
appointmentEndTime: at("2026-06-08T12:00:00Z"),
travelMinsFromPrev: 15,
bufferMins: 15,
},
]);
expect(flags[1]).toEqual({
hasConflict: false,
scheduleGapMins: 90,
requiredGapMins: 30,
shortfallMins: -60,
});
});
it("treats a null travelMinsFromPrev as zero travel", () => {
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T10:05:00Z"),
appointmentEndTime: at("2026-06-08T11:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 15,
},
]);
// 5min gap vs 0 travel + 15 buffer = 15 required → conflict, shortfall 10.
expect(flags[1]!.hasConflict).toBe(true);
expect(flags[1]!.requiredGapMins).toBe(15);
expect(flags[1]!.shortfallMins).toBe(10);
});
it("flags overlapping appointments (negative gap) as conflicts", () => {
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T09:30:00Z"),
appointmentEndTime: at("2026-06-08T10:30:00Z"),
travelMinsFromPrev: 10,
bufferMins: 15,
},
]);
expect(flags[1]!.scheduleGapMins).toBe(-30);
expect(flags[1]!.hasConflict).toBe(true);
expect(flags[1]!.shortfallMins).toBe(55);
});
});
describe("recomputeLegsForOrder", () => {
it("returns null travel for an empty or single-point order", () => {
expect(recomputeLegsForOrder([])).toEqual([]);
expect(recomputeLegsForOrder([{ latitude: 40, longitude: -74 }])).toEqual([
{ travelMinsFromPrev: null, travelDistanceKmFromPrev: null },
]);
});
it("estimates each leg for the fixed given order without reordering", () => {
const pts = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 1 },
{ latitude: 0, longitude: 2 },
];
const legs = recomputeLegsForOrder(pts);
expect(legs).toHaveLength(3);
expect(legs[0]).toEqual({
travelMinsFromPrev: null,
travelDistanceKmFromPrev: null,
});
// Each leg equals estimateLeg between adjacent points (no optimization).
const e01 = estimateLeg(pts[0]!, pts[1]!);
const e12 = estimateLeg(pts[1]!, pts[2]!);
expect(legs[1]).toEqual({
travelMinsFromPrev: e01.mins,
travelDistanceKmFromPrev: e01.distanceKm,
});
expect(legs[2]).toEqual({
travelMinsFromPrev: e12.mins,
travelDistanceKmFromPrev: e12.distanceKm,
});
});
});