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(path: string, body: unknown): Promise { 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('/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 }), };