852b064972
Adds the core route engine for Mobile Groomer Route Optimization:
- src/services/routeOptimization.ts — order a day's geocoded stops via
Google Directions (optimizeWaypoints:true) when a Maps API key is
configured, else an offline nearest-neighbor TSP heuristic over
great-circle distance. Handles the >25-stop edge case by chunking into
sub-routes (Directions waypoint cap) with a user warning, degrades to
the heuristic if Google errors mid-run, and resolves the API key from
businessSettings.googleMapsApiKey (decrypted) / GOOGLE_MAPS_API_KEY.
- src/routes/routes.ts — GET /api/routes/daily (fetch/create draft route
+ enriched stops) and POST /api/routes/optimize (generate/re-optimize,
persist stopOrder + travelMinsFromPrev + travelDistanceKmFromPrev and
route totals/optimizedAt in one transaction). Auth: manager (any) or
groomer (own route only); receptionists denied. Un-geocoded
appointments are skipped and surfaced.
- src/index.ts — mount /api/routes under requireRole("manager","groomer").
- Unit tests for haversine, leg estimation, nearest-neighbor ordering,
the Google path (mocked fetch), chunking, and fallback.
- UAT_PLAYBOOK.md §4.16 — new Route Optimization test cases.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
haversineKm,
|
|
estimateLeg,
|
|
nearestNeighborOrder,
|
|
optimizeRoute,
|
|
MAX_STOPS_PER_ROUTE,
|
|
type RouteStopInput,
|
|
} from "../services/routeOptimization.js";
|
|
import type { FetchLike } from "../services/geocoding.js";
|
|
|
|
/** Builds a fake fetch returning a single JSON body, recording called URLs. */
|
|
function fakeFetch(
|
|
body: unknown,
|
|
init: { ok?: boolean; status?: number; statusText?: string } = {}
|
|
): { fetchImpl: FetchLike; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const fetchImpl: FetchLike = async (url) => {
|
|
calls.push(url);
|
|
return {
|
|
ok: init.ok ?? true,
|
|
status: init.status ?? 200,
|
|
statusText: init.statusText ?? "OK",
|
|
json: async () => body,
|
|
};
|
|
};
|
|
return { fetchImpl, calls };
|
|
}
|
|
|
|
function stop(appointmentId: string, lat: number, lng: number): RouteStopInput {
|
|
return { appointmentId, latitude: lat, longitude: lng };
|
|
}
|
|
|
|
describe("haversineKm", () => {
|
|
it("is zero for the same point", () => {
|
|
expect(haversineKm({ latitude: 40, longitude: -74 }, { latitude: 40, longitude: -74 })).toBe(0);
|
|
});
|
|
|
|
it("approximates 1 degree of latitude as ~111km", () => {
|
|
const d = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 1, longitude: 0 });
|
|
expect(d).toBeGreaterThan(110);
|
|
expect(d).toBeLessThan(112);
|
|
});
|
|
});
|
|
|
|
describe("estimateLeg", () => {
|
|
it("applies the circuity factor and average speed", () => {
|
|
const a = { latitude: 0, longitude: 0 };
|
|
const b = { latitude: 0, longitude: 1 };
|
|
const { distanceKm, mins } = estimateLeg(a, b);
|
|
// ~111km straight * 1.3 circuity ≈ 144.6km; at 40km/h ≈ 217 min
|
|
expect(distanceKm).toBeGreaterThan(140);
|
|
expect(distanceKm).toBeLessThan(150);
|
|
expect(mins).toBeGreaterThan(200);
|
|
expect(mins).toBeLessThan(230);
|
|
expect(Number.isInteger(mins)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("nearestNeighborOrder", () => {
|
|
it("returns trivial order for 0 or 1 points", () => {
|
|
expect(nearestNeighborOrder([])).toEqual([]);
|
|
expect(nearestNeighborOrder([{ latitude: 1, longitude: 1 }])).toEqual([0]);
|
|
});
|
|
|
|
it("greedily visits the nearest unvisited point", () => {
|
|
// Points on a line; scrambled input order.
|
|
const points = [
|
|
{ latitude: 0, longitude: 0 }, // 0 (start)
|
|
{ latitude: 0, longitude: 5 }, // 1 (far)
|
|
{ latitude: 0, longitude: 1 }, // 2
|
|
{ latitude: 0, longitude: 2 }, // 3
|
|
];
|
|
expect(nearestNeighborOrder(points, 0)).toEqual([0, 2, 3, 1]);
|
|
});
|
|
});
|
|
|
|
describe("optimizeRoute — nearest-neighbor fallback (no API key)", () => {
|
|
it("returns an empty route for no stops", async () => {
|
|
const r = await optimizeRoute([]);
|
|
expect(r.stops).toHaveLength(0);
|
|
expect(r.totalTravelMins).toBe(0);
|
|
expect(r.totalDistanceKm).toBe(0);
|
|
expect(r.provider).toBe("nearest_neighbor");
|
|
expect(r.chunked).toBe(false);
|
|
});
|
|
|
|
it("handles a single stop with null travel-from-prev", async () => {
|
|
const r = await optimizeRoute([stop("a", 40, -74)]);
|
|
expect(r.stops).toHaveLength(1);
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
expect(r.stops[0]!.travelDistanceKmFromPrev).toBeNull();
|
|
expect(r.totalTravelMins).toBe(0);
|
|
});
|
|
|
|
it("orders multiple stops greedily and sums totals", async () => {
|
|
const stops = [
|
|
stop("start", 0, 0),
|
|
stop("far", 0, 5),
|
|
stop("near1", 0, 1),
|
|
stop("near2", 0, 2),
|
|
];
|
|
const r = await optimizeRoute(stops);
|
|
expect(r.provider).toBe("nearest_neighbor");
|
|
expect(r.stops.map((s) => s.appointmentId)).toEqual([
|
|
"start",
|
|
"near1",
|
|
"near2",
|
|
"far",
|
|
]);
|
|
// First stop has no inbound leg.
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
// Remaining stops have positive travel.
|
|
for (const s of r.stops.slice(1)) {
|
|
expect(s.travelMinsFromPrev!).toBeGreaterThan(0);
|
|
expect(s.travelDistanceKmFromPrev!).toBeGreaterThan(0);
|
|
}
|
|
const summed = r.stops.reduce((acc, s) => acc + (s.travelMinsFromPrev ?? 0), 0);
|
|
expect(r.totalTravelMins).toBe(summed);
|
|
});
|
|
});
|
|
|
|
describe("optimizeRoute — Google Directions path", () => {
|
|
it("uses optimized waypoint order and real leg metrics, dropping the return leg", async () => {
|
|
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
|
|
// waypoints = [B, C]; optimizer reorders them to [C, B] (waypoint_order [1,0]).
|
|
// legs: A->C, C->B, B->A(return). The return leg must be dropped.
|
|
const { fetchImpl, calls } = fakeFetch({
|
|
status: "OK",
|
|
routes: [
|
|
{
|
|
waypoint_order: [1, 0],
|
|
legs: [
|
|
{ distance: { value: 2000 }, duration: { value: 600 } }, // A->C
|
|
{ distance: { value: 1000 }, duration: { value: 300 } }, // C->B
|
|
{ distance: { value: 3000 }, duration: { value: 900 } }, // B->A (return, dropped)
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
|
|
expect(r.provider).toBe("google");
|
|
expect(r.stops.map((s) => s.appointmentId)).toEqual(["A", "C", "B"]);
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
expect(r.stops[1]!.travelDistanceKmFromPrev).toBe(2); // 2000m -> 2km
|
|
expect(r.stops[1]!.travelMinsFromPrev).toBe(10); // 600s -> 10min
|
|
expect(r.stops[2]!.travelDistanceKmFromPrev).toBe(1); // 1000m
|
|
expect(r.stops[2]!.travelMinsFromPrev).toBe(5); // 300s
|
|
expect(r.totalDistanceKm).toBe(3);
|
|
expect(r.totalTravelMins).toBe(15);
|
|
expect(decodeURIComponent(calls[0]!)).toContain("optimize:true");
|
|
});
|
|
|
|
it("falls back to the heuristic when Google returns a non-OK status", async () => {
|
|
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
|
|
const { fetchImpl } = fakeFetch({ status: "REQUEST_DENIED", error_message: "bad key" });
|
|
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
|
|
// Provider label reflects the chosen strategy (google requested) but a
|
|
// warning records the degradation and stops are still ordered.
|
|
expect(r.stops).toHaveLength(3);
|
|
expect(r.warnings.some((w) => w.includes("offline heuristic"))).toBe(true);
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("optimizeRoute — >25 stop chunking", () => {
|
|
it("splits into sub-routes with a warning and continuous stop ordering", async () => {
|
|
const stops: RouteStopInput[] = [];
|
|
for (let i = 0; i < MAX_STOPS_PER_ROUTE + 5; i++) {
|
|
stops.push(stop(`s${i}`, 0, i * 0.1));
|
|
}
|
|
const r = await optimizeRoute(stops);
|
|
expect(r.chunked).toBe(true);
|
|
expect(r.subRouteCount).toBe(2);
|
|
expect(r.warnings.some((w) => w.includes("sub-routes"))).toBe(true);
|
|
expect(r.stops).toHaveLength(MAX_STOPS_PER_ROUTE + 5);
|
|
// Only the very first stop of the whole route lacks an inbound leg.
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
expect(r.stops.slice(1).every((s) => s.travelMinsFromPrev !== null)).toBe(true);
|
|
// All appointment ids preserved exactly once.
|
|
expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length);
|
|
});
|
|
});
|