import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign'; 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. * * ── `riderassign` assigns against OUR fleet, not a foreign one ────────────── * * This file used to say the opposite — that `riderassign` was useless because * it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours". * That was wrong, and it was wrong for the ordinary reason: an unfamiliar id * was taken for a stranger without checking the roster. * * Checked on 2026-09-10. `getriderroster?partnerid=44` lists 883 "Rajan A", and * so do the rider ids on tenant 916's own delivery rows — 883, 897, 950, 1111, * 1114, every one of them partner 44's, which is the Coimbatore fleet. The * solver reads the same database Fiesta does: `getallriders` on jupiter and * `getriders` on Fiesta return identical rosters and identical on-duty state. * * So auto-assignment works and `assign` below wires it up. * * ── What it cannot do yet, and why that is not our bug ────────────────────── * * The solver picks the riders itself, gated on `onduty = 1`, and that flag is 0 * for all 118 riders on the platform — every region, checked the same day. So * `active_riders_pool` is 0 and every order comes back unassigned with "No * riders found (check partner online status)". Supplying riders in the body * does not help: `riders` and `active_riders` were both tried against a rider * the on-duty endpoint DOES report, and the pool stayed 0. * * Whatever is meant to set `onduty` is not setting it. That is worth asking the * app team about; nothing here can work around it. * * ── `routemate` is gone ───────────────────────────────────────────────────── * * The old console's second mode posted to `routemate.workolik.com/api/v1/ * optimization/riderassign?strategy=multi_trip`, which accepted a rider list * inline. It answers 404 now, with and without the query string, so that route * around the `onduty` gate is closed too. */ const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1'; /** A solve can legitimately take a while. Past this, something is wrong. */ const SOLVE_TIMEOUT_MS = 90_000; /** 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; } /** * A run that is allowed to take its time, and to be cancelled. * * Separate from `post` for two reasons: the caller needs the whole envelope * rather than `details`, and a solve is slow enough that abandoning it has to * be possible. The caller's cancel and the timeout both have to be able to stop * it, so they are combined rather than one winning. */ async function postRaw(path: string, body: unknown, signal?: AbortSignal): Promise { const timer = new AbortController(); const stop = setTimeout(() => timer.abort(), SOLVE_TIMEOUT_MS); const onAbort = () => timer.abort(); signal?.addEventListener('abort', onAbort); try { const response = await fetch(`${OPTIMISER_BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), signal: timer.signal, }); if (!response.ok) { // 422 is the solver rejecting the payload and saying which field. Worth // showing verbatim — "422" on its own is not actionable. const text = await response.text().catch(() => ''); throw new OptimiserError( text.trim().slice(0, 400) || `Optimiser refused the request (HTTP ${response.status})`, ); } return await response.json(); } catch (error) { if (error instanceof OptimiserError) throw error; if ((error as Error)?.name === 'AbortError') { throw new OptimiserError( signal?.aborted ? 'Cancelled.' : 'The optimiser did not answer in time. Nothing was assigned — the orders are untouched.', ); } throw new OptimiserError('Could not reach the route optimiser'); } finally { clearTimeout(stop); signal?.removeEventListener('abort', onAbort); } } 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 }), /** * Propose a rider for each waiting order. * * A PLAN, not a commitment. Nothing is written anywhere until the operator * accepts it and the console makes its own `createdeliveries` call to Fiesta * through `buildDelivery` — the same path the manual assign bar uses, so * there is exactly one way a delivery is ever written. Safe to run twice and * safe to walk away from. * * ── Raw, not unwrapped ──────────────────────────────────────────────────── * * `postRaw`, because the answer here IS the envelope: `zones` carries the * assignment, `meta` carries the accounting and the per-order reasons, and * `details` is only the flat fallback shape. `post` would hand back `details` * alone and throw the plan away — and `details` is `[]` on every run that * assigns nothing, which is every run today. * * ── Slow on purpose ─────────────────────────────────────────────────────── * * Seven seconds for five orders, measured, and it is a solver so it grows * with the problem. `signal` is taken so a caller can offer to cancel; the * timeout is deliberately generous, since killing a run early abandons work * the operator is waiting on and teaches them the button is broken. * * `tuning` steers it — balanced, aggressive_speed, fuel_saver, zone_strict — * and the literal string `null` is a value it accepts, meaning "your default". */ assign: (request: SolverRequest, tuning: Tuning | null, signal?: AbortSignal) => postRaw( `/optimization/riderassign?hypertuning_params=${tuning ?? 'null'}`, request, signal, ), };