ca62fb8ef6
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
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
import { businessSettings, decryptSecret, type Db } from "@groombook/db";
|
||
import type { FetchLike } from "./geocoding.js";
|
||
|
||
/**
|
||
* Route optimization service (GRO-2155, Phase 2.1 of Route Optimization).
|
||
*
|
||
* Given a groomer's geocoded stops for a day, produces an optimized visiting
|
||
* order plus per-leg and total travel estimates. Two strategies:
|
||
*
|
||
* - {@link optimizeWithGoogle}: Google Maps Directions API with
|
||
* `optimizeWaypoints: true` (real road durations/distances), used when a
|
||
* Google Maps API key is configured.
|
||
* - {@link nearestNeighborOrder}: an offline nearest-neighbor TSP heuristic over
|
||
* great-circle distance, used as the default free / no-API-key fallback.
|
||
*
|
||
* Both strategies share the same public {@link optimizeRoute} orchestrator,
|
||
* which also handles the >25-stop edge case by chunking into sub-routes (the
|
||
* Google Directions waypoint cap) and surfacing a warning.
|
||
*/
|
||
|
||
/** Google Directions allows origin + destination + up to 23 waypoints = 25
|
||
* points per request. We cap a sub-route at 25 stops and chunk beyond that. */
|
||
export const MAX_STOPS_PER_ROUTE = 25;
|
||
|
||
/** Average driving speed (km/h) used to convert distance into travel minutes in
|
||
* the offline heuristic. Tuned for mixed urban/suburban mobile-groomer routes. */
|
||
export const AVG_SPEED_KMH = 40;
|
||
|
||
/** Multiplier applied to great-circle distance to approximate real road
|
||
* distance in the offline heuristic (straight-line underestimates driving). */
|
||
export const ROAD_CIRCUITY_FACTOR = 1.3;
|
||
|
||
const EARTH_RADIUS_KM = 6371;
|
||
|
||
/** A geocoded stop to be ordered. `appointmentId` ties it back to the schedule. */
|
||
export interface RouteStopInput {
|
||
appointmentId: string;
|
||
latitude: number;
|
||
longitude: number;
|
||
}
|
||
|
||
/** A single stop in the optimized order, with travel from the previous stop. */
|
||
export interface OptimizedStop {
|
||
appointmentId: string;
|
||
latitude: number;
|
||
longitude: number;
|
||
/** Null for the first stop of the whole route. */
|
||
travelMinsFromPrev: number | null;
|
||
/** Null for the first stop of the whole route. Kilometres, 2-dp. */
|
||
travelDistanceKmFromPrev: number | null;
|
||
}
|
||
|
||
export type RouteOptimizationProvider = "google" | "nearest_neighbor";
|
||
|
||
export interface OptimizedRoute {
|
||
provider: RouteOptimizationProvider;
|
||
stops: OptimizedStop[];
|
||
totalTravelMins: number;
|
||
/** Kilometres, rounded to 2 decimal places. */
|
||
totalDistanceKm: number;
|
||
/** True when the route was split into multiple sub-routes (>25 stops). */
|
||
chunked: boolean;
|
||
subRouteCount: number;
|
||
/** Non-fatal advisories for the caller to surface to the user. */
|
||
warnings: string[];
|
||
}
|
||
|
||
export interface OptimizeRouteOptions {
|
||
/** Google Maps API key. When absent, the nearest-neighbor heuristic is used. */
|
||
googleApiKey?: string | null;
|
||
/** Injectable fetch for testing the Google path. Defaults to global fetch. */
|
||
fetchImpl?: FetchLike;
|
||
}
|
||
|
||
const defaultFetch: FetchLike = (input, init) =>
|
||
(globalThis.fetch as unknown as FetchLike)(input, init);
|
||
|
||
// ─── Geometry helpers ───────────────────────────────────────────────────────
|
||
|
||
function toRadians(deg: number): number {
|
||
return (deg * Math.PI) / 180;
|
||
}
|
||
|
||
/** Great-circle distance between two coordinates, in kilometres. */
|
||
export function haversineKm(
|
||
a: { latitude: number; longitude: number },
|
||
b: { latitude: number; longitude: number }
|
||
): number {
|
||
const dLat = toRadians(b.latitude - a.latitude);
|
||
const dLon = toRadians(b.longitude - a.longitude);
|
||
const lat1 = toRadians(a.latitude);
|
||
const lat2 = toRadians(b.latitude);
|
||
const h =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||
}
|
||
|
||
/** Round to 2 decimal places, returning a finite number. */
|
||
function round2(n: number): number {
|
||
return Math.round(n * 100) / 100;
|
||
}
|
||
|
||
/**
|
||
* Estimate a road travel leg from the great-circle distance between two points.
|
||
* Applies a circuity factor for distance and a fixed average speed for time.
|
||
*/
|
||
export function estimateLeg(
|
||
a: { latitude: number; longitude: number },
|
||
b: { latitude: number; longitude: number }
|
||
): { distanceKm: number; mins: number } {
|
||
const straight = haversineKm(a, b);
|
||
const distanceKm = straight * ROAD_CIRCUITY_FACTOR;
|
||
const mins = (distanceKm / AVG_SPEED_KMH) * 60;
|
||
return { distanceKm: round2(distanceKm), mins: Math.round(mins) };
|
||
}
|
||
|
||
// ─── Nearest-neighbor heuristic ─────────────────────────────────────────────
|
||
|
||
/**
|
||
* Orders points greedily: start at `startIndex`, then repeatedly visit the
|
||
* nearest unvisited point (great-circle distance). Returns indices into the
|
||
* input array in visiting order. Deterministic ties broken by lowest index.
|
||
*/
|
||
export function nearestNeighborOrder(
|
||
points: Array<{ latitude: number; longitude: number }>,
|
||
startIndex = 0
|
||
): number[] {
|
||
const n = points.length;
|
||
if (n <= 1) return points.map((_, i) => i);
|
||
|
||
const visited = new Array<boolean>(n).fill(false);
|
||
const order: number[] = [startIndex];
|
||
visited[startIndex] = true;
|
||
let current = startIndex;
|
||
|
||
for (let step = 1; step < n; step++) {
|
||
let best = -1;
|
||
let bestDist = Infinity;
|
||
for (let j = 0; j < n; j++) {
|
||
if (visited[j]) continue;
|
||
const d = haversineKm(points[current]!, points[j]!);
|
||
if (d < bestDist) {
|
||
bestDist = d;
|
||
best = j;
|
||
}
|
||
}
|
||
visited[best] = true;
|
||
order.push(best);
|
||
current = best;
|
||
}
|
||
return order;
|
||
}
|
||
|
||
/** Orders one chunk (<= MAX_STOPS_PER_ROUTE) via nearest-neighbor. */
|
||
function optimizeChunkNearestNeighbor(
|
||
stops: RouteStopInput[]
|
||
): RouteStopInput[] {
|
||
const order = nearestNeighborOrder(stops, 0);
|
||
return order.map((i) => stops[i]!);
|
||
}
|
||
|
||
// ─── Google Directions ──────────────────────────────────────────────────────
|
||
|
||
const GOOGLE_DIRECTIONS_URL =
|
||
"https://maps.googleapis.com/maps/api/directions/json";
|
||
|
||
interface GoogleDirectionsResponse {
|
||
status: string;
|
||
error_message?: string;
|
||
routes?: Array<{
|
||
waypoint_order?: number[];
|
||
legs?: Array<{
|
||
duration?: { value?: number };
|
||
distance?: { value?: number };
|
||
}>;
|
||
}>;
|
||
}
|
||
|
||
/**
|
||
* Orders one chunk via the Google Directions API with `optimizeWaypoints=true`.
|
||
*
|
||
* The first stop is fixed as both origin and destination (a closed tour); the
|
||
* remaining stops are passed as optimizable waypoints. We keep the optimized
|
||
* forward order and drop the final return-to-origin leg, yielding an open route
|
||
* whose per-leg durations/distances come from real road data.
|
||
*/
|
||
async function optimizeChunkGoogle(
|
||
stops: RouteStopInput[],
|
||
apiKey: string,
|
||
fetchImpl: FetchLike
|
||
): Promise<{ stops: RouteStopInput[]; legsMeters: number[]; legsSeconds: number[] }> {
|
||
if (stops.length <= 1) {
|
||
return { stops: [...stops], legsMeters: [], legsSeconds: [] };
|
||
}
|
||
|
||
const origin = stops[0]!;
|
||
const waypoints = stops.slice(1);
|
||
const url = new URL(GOOGLE_DIRECTIONS_URL);
|
||
url.searchParams.set("origin", `${origin.latitude},${origin.longitude}`);
|
||
url.searchParams.set("destination", `${origin.latitude},${origin.longitude}`);
|
||
url.searchParams.set(
|
||
"waypoints",
|
||
"optimize:true|" +
|
||
waypoints.map((w) => `${w.latitude},${w.longitude}`).join("|")
|
||
);
|
||
url.searchParams.set("key", apiKey);
|
||
|
||
const res = await fetchImpl(url.toString());
|
||
if (!res.ok) {
|
||
throw new Error(
|
||
`Google Directions request failed: ${res.status} ${res.statusText}`
|
||
);
|
||
}
|
||
const body = (await res.json()) as GoogleDirectionsResponse;
|
||
if (body.status !== "OK" || !body.routes || body.routes.length === 0) {
|
||
throw new Error(
|
||
`Google Directions returned status ${body.status}${
|
||
body.error_message ? `: ${body.error_message}` : ""
|
||
}`
|
||
);
|
||
}
|
||
|
||
const route = body.routes[0]!;
|
||
const waypointOrder = route.waypoint_order ?? waypoints.map((_, i) => i);
|
||
const legs = route.legs ?? [];
|
||
|
||
// Ordered stops: origin first, then waypoints in the optimized order.
|
||
const orderedStops: RouteStopInput[] = [
|
||
origin,
|
||
...waypointOrder.map((i) => waypoints[i]!),
|
||
];
|
||
|
||
// legs[k] is the travel into orderedStops[k+1]. Drop the trailing return leg
|
||
// (orderedStops.length-1 legs describe the open route).
|
||
const legsMeters: number[] = [];
|
||
const legsSeconds: number[] = [];
|
||
for (let k = 0; k < orderedStops.length - 1; k++) {
|
||
const leg = legs[k];
|
||
legsMeters.push(leg?.distance?.value ?? 0);
|
||
legsSeconds.push(leg?.duration?.value ?? 0);
|
||
}
|
||
|
||
return { stops: orderedStops, legsMeters, legsSeconds };
|
||
}
|
||
|
||
// ─── Orchestration ──────────────────────────────────────────────────────────
|
||
|
||
function chunk<T>(items: T[], size: number): T[][] {
|
||
const out: T[][] = [];
|
||
for (let i = 0; i < items.length; i += size) {
|
||
out.push(items.slice(i, i + size));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Optimizes a full day's stops into a single visiting order with travel
|
||
* metrics. Uses Google Directions when `googleApiKey` is provided, otherwise the
|
||
* offline nearest-neighbor heuristic. Routes longer than
|
||
* {@link MAX_STOPS_PER_ROUTE} stops are split into sub-routes and a warning is
|
||
* emitted; sub-routes are stitched end-to-end, with the boundary leg estimated
|
||
* from great-circle distance.
|
||
*/
|
||
export async function optimizeRoute(
|
||
inputStops: RouteStopInput[],
|
||
options: OptimizeRouteOptions = {}
|
||
): Promise<OptimizedRoute> {
|
||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||
const useGoogle = Boolean(options.googleApiKey);
|
||
const provider: RouteOptimizationProvider = useGoogle
|
||
? "google"
|
||
: "nearest_neighbor";
|
||
const warnings: string[] = [];
|
||
|
||
if (inputStops.length === 0) {
|
||
return {
|
||
provider,
|
||
stops: [],
|
||
totalTravelMins: 0,
|
||
totalDistanceKm: 0,
|
||
chunked: false,
|
||
subRouteCount: 0,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
const chunks = chunk(inputStops, MAX_STOPS_PER_ROUTE);
|
||
const chunked = chunks.length > 1;
|
||
if (chunked) {
|
||
warnings.push(
|
||
`Route has ${inputStops.length} stops, exceeding the ${MAX_STOPS_PER_ROUTE}-stop optimization limit. Split into ${chunks.length} sub-routes; review the order at sub-route boundaries.`
|
||
);
|
||
}
|
||
|
||
const ordered: OptimizedStop[] = [];
|
||
let prev: RouteStopInput | null = null;
|
||
|
||
for (const group of chunks) {
|
||
let groupStops: RouteStopInput[];
|
||
let legDistanceKm: (i: number) => number;
|
||
let legMins: (i: number) => number;
|
||
|
||
if (useGoogle) {
|
||
try {
|
||
const result = await optimizeChunkGoogle(
|
||
group,
|
||
options.googleApiKey!,
|
||
fetchImpl
|
||
);
|
||
groupStops = result.stops;
|
||
legDistanceKm = (i) => round2(result.legsMeters[i]! / 1000);
|
||
legMins = (i) => Math.round(result.legsSeconds[i]! / 60);
|
||
} catch (err) {
|
||
// Google failed mid-optimization — degrade to the offline heuristic for
|
||
// this run rather than failing the whole request.
|
||
warnings.push(
|
||
`Google Directions unavailable; used offline heuristic: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`
|
||
);
|
||
groupStops = optimizeChunkNearestNeighbor(group);
|
||
legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm;
|
||
legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins;
|
||
}
|
||
} else {
|
||
groupStops = optimizeChunkNearestNeighbor(group);
|
||
legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm;
|
||
legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins;
|
||
}
|
||
|
||
for (let i = 0; i < groupStops.length; i++) {
|
||
const stop = groupStops[i]!;
|
||
if (prev === null) {
|
||
// Very first stop of the whole route.
|
||
ordered.push({
|
||
appointmentId: stop.appointmentId,
|
||
latitude: stop.latitude,
|
||
longitude: stop.longitude,
|
||
travelMinsFromPrev: null,
|
||
travelDistanceKmFromPrev: null,
|
||
});
|
||
} else if (i === 0) {
|
||
// First stop of a non-initial chunk: estimate the boundary leg.
|
||
const est = estimateLeg(prev, stop);
|
||
ordered.push({
|
||
appointmentId: stop.appointmentId,
|
||
latitude: stop.latitude,
|
||
longitude: stop.longitude,
|
||
travelMinsFromPrev: est.mins,
|
||
travelDistanceKmFromPrev: est.distanceKm,
|
||
});
|
||
} else {
|
||
ordered.push({
|
||
appointmentId: stop.appointmentId,
|
||
latitude: stop.latitude,
|
||
longitude: stop.longitude,
|
||
travelMinsFromPrev: legMins(i - 1),
|
||
travelDistanceKmFromPrev: legDistanceKm(i - 1),
|
||
});
|
||
}
|
||
prev = stop;
|
||
}
|
||
}
|
||
|
||
const totalTravelMins = ordered.reduce(
|
||
(sum, s) => sum + (s.travelMinsFromPrev ?? 0),
|
||
0
|
||
);
|
||
const totalDistanceKm = round2(
|
||
ordered.reduce((sum, s) => sum + (s.travelDistanceKmFromPrev ?? 0), 0)
|
||
);
|
||
|
||
return {
|
||
provider,
|
||
stops: ordered,
|
||
totalTravelMins,
|
||
totalDistanceKm,
|
||
chunked,
|
||
subRouteCount: chunks.length,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
// ─── Google API key resolution ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Resolves the Google Maps API key for route optimization from
|
||
* `businessSettings.googleMapsApiKey` (decrypted at rest) or, as a development
|
||
* convenience, the `GOOGLE_MAPS_API_KEY` env var. Returns `null` when no usable
|
||
* key exists, in which case callers fall back to the offline heuristic.
|
||
*/
|
||
export async function resolveRouteGoogleApiKey(
|
||
db: Db,
|
||
decrypt: (ciphertext: string) => string = decryptSecret
|
||
): Promise<string | null> {
|
||
const [settings] = await db.select().from(businessSettings).limit(1);
|
||
const stored = settings?.googleMapsApiKey?.trim();
|
||
if (stored) {
|
||
try {
|
||
const decrypted = decrypt(stored).trim();
|
||
if (decrypted) return decrypted;
|
||
} catch (err) {
|
||
console.warn(
|
||
`Failed to decrypt googleMapsApiKey for route optimization; using offline heuristic: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
|
||
return fromEnv ? fromEnv : null;
|
||
}
|
||
|
||
// ─── Travel buffer & schedule-conflict logic (GRO-2156, Phase 2.2) ───────────
|
||
|
||
/** A single stop's timing inputs for schedule-conflict detection. */
|
||
export interface ScheduleStopTiming {
|
||
/** Scheduled appointment start. */
|
||
appointmentStartTime: Date;
|
||
/** Scheduled appointment end. */
|
||
appointmentEndTime: Date;
|
||
/** Travel minutes into this stop from the previous one (null for first). */
|
||
travelMinsFromPrev: number | null;
|
||
/** Configured buffer minutes before this stop. */
|
||
bufferMins: number;
|
||
}
|
||
|
||
/** Conflict annotation for one stop, surfaced for the frontend to display. */
|
||
export interface StopConflictFlags {
|
||
/** True when the schedule gap is too tight for travel + buffer. */
|
||
hasConflict: boolean;
|
||
/** Minutes between the previous appointment's end and this one's start.
|
||
* Null for the first stop (no predecessor). */
|
||
scheduleGapMins: number | null;
|
||
/** travelMinsFromPrev + bufferMins. Null for the first stop. */
|
||
requiredGapMins: number | null;
|
||
/** requiredGapMins − scheduleGapMins; positive when the schedule is tight.
|
||
* Null for the first stop. */
|
||
shortfallMins: number | null;
|
||
}
|
||
|
||
const MS_PER_MIN = 60_000;
|
||
|
||
/**
|
||
* Detects "tight schedule" conflicts between consecutive stops, in visiting
|
||
* order. A conflict exists when the real gap between the previous appointment's
|
||
* end and this appointment's start is smaller than the time needed to travel
|
||
* plus the configured buffer (`travelMinsFromPrev + bufferMins`).
|
||
*
|
||
* This only *flags* conflicts — appointments are never moved. The first stop
|
||
* has no predecessor and is therefore always conflict-free.
|
||
*/
|
||
export function detectScheduleConflicts(
|
||
stops: ScheduleStopTiming[]
|
||
): StopConflictFlags[] {
|
||
return stops.map((s, i) => {
|
||
if (i === 0) {
|
||
return {
|
||
hasConflict: false,
|
||
scheduleGapMins: null,
|
||
requiredGapMins: null,
|
||
shortfallMins: null,
|
||
};
|
||
}
|
||
const prev = stops[i - 1]!;
|
||
const scheduleGapMins = Math.round(
|
||
(s.appointmentStartTime.getTime() - prev.appointmentEndTime.getTime()) /
|
||
MS_PER_MIN
|
||
);
|
||
const requiredGapMins = (s.travelMinsFromPrev ?? 0) + s.bufferMins;
|
||
const shortfallMins = requiredGapMins - scheduleGapMins;
|
||
return {
|
||
hasConflict: shortfallMins > 0,
|
||
scheduleGapMins,
|
||
requiredGapMins,
|
||
shortfallMins,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** A coordinate used when recomputing legs for a fixed (manually chosen) order. */
|
||
export interface OrderedPoint {
|
||
latitude: number;
|
||
longitude: number;
|
||
}
|
||
|
||
/** Recomputed per-leg travel for a fixed stop order. */
|
||
export interface RecomputedLeg {
|
||
/** Null for the first stop. */
|
||
travelMinsFromPrev: number | null;
|
||
/** Null for the first stop. Kilometres, 2-dp. */
|
||
travelDistanceKmFromPrev: number | null;
|
||
}
|
||
|
||
/**
|
||
* Recomputes per-leg travel estimates for a *fixed* visiting order (e.g. after a
|
||
* manual reorder). Unlike {@link optimizeRoute} this does not reorder anything —
|
||
* it walks the given order and estimates each leg offline via {@link estimateLeg}
|
||
* so a manual drag does not consume Google Directions quota.
|
||
*/
|
||
export function recomputeLegsForOrder(points: OrderedPoint[]): RecomputedLeg[] {
|
||
return points.map((p, i) => {
|
||
if (i === 0) {
|
||
return { travelMinsFromPrev: null, travelDistanceKmFromPrev: null };
|
||
}
|
||
const est = estimateLeg(points[i - 1]!, p);
|
||
return {
|
||
travelMinsFromPrev: est.mins,
|
||
travelDistanceKmFromPrev: est.distanceKm,
|
||
};
|
||
});
|
||
}
|