Files
daily_console_web/src/api/optimiser.ts
2026-09-10 17:57:00 +05:30

245 lines
11 KiB
TypeScript

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<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;
}
/**
* 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<unknown> {
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<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 }),
/**
* 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,
),
};