136 lines
5.6 KiB
TypeScript
136 lines
5.6 KiB
TypeScript
import type { OrderRow } from './types';
|
|
|
|
/**
|
|
* The route optimiser.
|
|
*
|
|
* A SEPARATE SERVICE from Fiesta — `routes.workolik.com`, "Route Optimization
|
|
* API v2.0.0" — so it does not go through `client.ts`, which exists to talk to
|
|
* one backend. Road routing is real (a Valhalla backend, not straight lines)
|
|
* and the assignment model is trained: 3,627 records, tuned to 20 orders per
|
|
* rider and an ideal load of 4.
|
|
*
|
|
* ── What it is and is not ───────────────────────────────────────────────────
|
|
*
|
|
* `optimization/createdeliveries` is NOT a create, despite the name it shares
|
|
* with Fiesta's. It is a pure function: send an array of orders, get the same
|
|
* array back reordered nearest-neighbour with `step`, `previouskms`,
|
|
* `cumulativekms`, `actualkms` and `eta` added. Its own docs say forwarding is
|
|
* paused, and the verified behaviour matches — it writes nothing anywhere.
|
|
*
|
|
* So the sequence is ours to commit: we take its answer and post it to Fiesta's
|
|
* `deliveries/createdeliveries` ourselves. That is also what the xpress console
|
|
* does, which is the only reason its two identically-named endpoints do not
|
|
* collide.
|
|
*
|
|
* ── What we deliberately do not call ────────────────────────────────────────
|
|
*
|
|
* `optimization/riderassign` works and is useless to us: it assigns against its
|
|
* OWN fleet. Sending our orders returned them assigned to `rider_id 883,
|
|
* "Rajan A"` — not one of ours, and no parameter changes that. Auto-assignment
|
|
* needs either a riders-inline variant of that endpoint or a mapping onto
|
|
* `routemate`'s `doormile/assign`, which does accept `milers` inline. Neither
|
|
* is wired here until somebody decides which.
|
|
*/
|
|
|
|
const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1';
|
|
|
|
/** An order as the optimiser hands it back — ours, plus the routing it added. */
|
|
export interface SequencedStop extends OrderRow {
|
|
/** 1..N. The order to visit in. */
|
|
step?: number;
|
|
/** Kilometres from the previous stop. */
|
|
previouskms?: number;
|
|
/** Running total for the round. */
|
|
cumulativekms?: number;
|
|
/**
|
|
* Direct pickup-to-delivery distance, as a string.
|
|
*
|
|
* The service returns these as strings ("1.23"), which is also how Fiesta's
|
|
* `deliveries.kms` / `actualkms` columns are typed — so they carry across
|
|
* unconverted. Those columns are exactly the ones found holding the literal
|
|
* text "null" in production, which broke the rider summary; a sequence run is
|
|
* what should be filling them with real numbers.
|
|
*/
|
|
actualkms?: string;
|
|
kms?: string;
|
|
/** Minutes for this leg, and cumulative. Strings, as sent. */
|
|
eta?: string;
|
|
cumulative_eta?: string;
|
|
ordertype?: string;
|
|
}
|
|
|
|
/** One rider's leg of a plan, in the shape reconcile expects back. */
|
|
export interface PlannedRider {
|
|
rider_id: string | number;
|
|
rider_name?: string;
|
|
orders: SequencedStop[];
|
|
}
|
|
|
|
export class OptimiserError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'OptimiserError';
|
|
}
|
|
}
|
|
|
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${OPTIMISER_BASE}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch {
|
|
// A separate host means a separate failure mode: the optimiser can be down
|
|
// while Fiesta is fine. Said plainly so nobody debugs the wrong service.
|
|
throw new OptimiserError('Could not reach the route optimiser');
|
|
}
|
|
|
|
let payload: { code?: number; details?: T; message?: string; error?: { message?: string } };
|
|
try {
|
|
payload = await response.json();
|
|
} catch {
|
|
throw new OptimiserError(`The optimiser sent a malformed reply (HTTP ${response.status})`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new OptimiserError(payload?.error?.message || payload?.message || `Optimiser refused the request (HTTP ${response.status})`);
|
|
}
|
|
|
|
// It answers `{code, details}` on the sequencing route and a bare object
|
|
// elsewhere, so both shapes are unwrapped here rather than at each call site.
|
|
return (payload.details ?? (payload as unknown)) as T;
|
|
}
|
|
|
|
export const optimiserApi = {
|
|
/**
|
|
* Put a set of orders in a sensible order.
|
|
*
|
|
* Send them in any order; they come back sorted with a step number and the
|
|
* distance and time between each. Verified against the live service with our
|
|
* own field names — it reads `pickuplat`/`pickuplong` and
|
|
* `deliverylat`/`deliverylong`, which order rows already carry.
|
|
*/
|
|
sequence: (orders: OrderRow[]) =>
|
|
post<SequencedStop[]>('/optimization/createdeliveries', orders),
|
|
|
|
/**
|
|
* Repair step numbers after somebody moved a stop by hand.
|
|
*
|
|
* Moving one order between riders breaks two rounds at once: the rider who
|
|
* lost it has a hole in its sequence (1,2,3,5,6) and the one who gained it
|
|
* has a step that collides or is missing. This fixes both.
|
|
*
|
|
* It MUST run before the plan is committed. The team who built the page this
|
|
* came from call skipping it "the single biggest production bug to avoid in
|
|
* this area" — it corrupts route sequences in the database, and nothing at
|
|
* the point of the write can tell.
|
|
*
|
|
* Send only the riders that were edited; the response carries those riders
|
|
* back and the rest of the plan is left alone.
|
|
*/
|
|
reconcile: (riders: PlannedRider[]) =>
|
|
post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }),
|
|
};
|