245 lines
11 KiB
TypeScript
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,
|
|
),
|
|
};
|