updats on the dispatch page
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
|
||||
import type { OrderRow } from './types';
|
||||
|
||||
/**
|
||||
@@ -22,18 +23,46 @@ import type { OrderRow } from './types';
|
||||
* does, which is the only reason its two identically-named endpoints do not
|
||||
* collide.
|
||||
*
|
||||
* ── What we deliberately do not call ────────────────────────────────────────
|
||||
* ── `riderassign` assigns against OUR fleet, not a foreign one ──────────────
|
||||
*
|
||||
* `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.
|
||||
* 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. */
|
||||
@@ -103,6 +132,52 @@ async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
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.
|
||||
@@ -132,4 +207,38 @@ export const optimiserApi = {
|
||||
*/
|
||||
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,
|
||||
),
|
||||
};
|
||||
|
||||
135
src/api/routing.ts
Normal file
135
src/api/routing.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Real road geometry between two points.
|
||||
*
|
||||
* ── Why a third service ─────────────────────────────────────────────────────
|
||||
*
|
||||
* A straight line between two drops is not a route. On a map it cuts through
|
||||
* blocks and across rivers, and its length is not the distance anybody rode —
|
||||
* so a line drawn that way invites a measurement it cannot support. OSRM
|
||||
* returns the actual road path, which is both honest and immediately readable
|
||||
* as "they went round the one-way system".
|
||||
*
|
||||
* `router.project-osrm.org` is the project's own demo server. Verified reachable
|
||||
* 2026-09-10 (200 in ~1.1 s for a Coimbatore leg). It is a courtesy service with
|
||||
* no SLA and a fair-use policy, which shapes everything below: legs are cached,
|
||||
* requests are capped per draw, and a failure is silent because a map that
|
||||
* loses its road geometry is still a useful map.
|
||||
*
|
||||
* ── Failure is expected and must be cheap ───────────────────────────────────
|
||||
*
|
||||
* Every caller takes back a path or null, never an error. A null means "draw the
|
||||
* straight line instead", which is what the map already did. Nothing about a
|
||||
* dispatch board should break because a free routing server was busy.
|
||||
*/
|
||||
|
||||
const OSRM_BASE = (
|
||||
import.meta.env?.['VITE_OSRM_BASE'] ?? 'https://router.project-osrm.org'
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
/** One leg's road geometry, as [lat, lng] pairs ready for a polyline. */
|
||||
export type RoadPath = { lat: number; lng: number }[];
|
||||
|
||||
export interface Leg {
|
||||
from: { lat: number; lng: number };
|
||||
to: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legs already fetched, keyed on their rounded endpoints.
|
||||
*
|
||||
* Module-level and unbounded on purpose within a session: a dispatch board
|
||||
* redraws constantly — every filter, every poll — and the same legs recur. Two
|
||||
* hundred legs of geometry is a few hundred kilobytes, and re-fetching them
|
||||
* from a courtesy server on each render is the behaviour that gets an IP
|
||||
* blocked.
|
||||
*/
|
||||
const cache = new Map<string, RoadPath | null>();
|
||||
|
||||
/** Five decimal places is about a metre — finer than any two drops differ by. */
|
||||
function keyOf(leg: Leg): string {
|
||||
return `${leg.from.lat.toFixed(5)},${leg.from.lng.toFixed(5)};${leg.to.lat.toFixed(5)},${leg.to.lng.toFixed(5)}`;
|
||||
}
|
||||
|
||||
/** A single leg's road path, or null when it cannot be had. */
|
||||
async function fetchLeg(leg: Leg, signal?: AbortSignal): Promise<RoadPath | null> {
|
||||
const url =
|
||||
`${OSRM_BASE}/route/v1/driving/` +
|
||||
`${leg.from.lng},${leg.from.lat};${leg.to.lng},${leg.to.lat}` +
|
||||
`?overview=full&geometries=geojson`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json()) as {
|
||||
routes?: { geometry?: { coordinates?: [number, number][] } }[];
|
||||
};
|
||||
const coordinates = body.routes?.[0]?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
// GeoJSON is [lng, lat]; leaflet wants lat first. Getting this backwards
|
||||
// puts Coimbatore in the Arabian Sea, which is the classic symptom.
|
||||
return coordinates.map(([lng, lat]) => ({ lat, lng }));
|
||||
} catch {
|
||||
// Includes the abort. A cancelled draw wants no path, same as a failed one.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many legs one draw may ask for.
|
||||
*
|
||||
* A hundred-drop round is ninety-nine legs, and asking a courtesy server for
|
||||
* all of them at once is how a shared IP gets rate-limited for everybody. Past
|
||||
* this the map falls back to straight lines, which it can always draw.
|
||||
*/
|
||||
const MAX_LEGS_PER_DRAW = 60;
|
||||
|
||||
/** How many requests are in flight at once. Polite, and enough to feel instant. */
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
export const routingApi = {
|
||||
/**
|
||||
* Road geometry for a set of legs.
|
||||
*
|
||||
* Returns a map keyed the same way the caller can look up — `keyFor(leg)` —
|
||||
* holding a path or null per leg. Cached legs cost nothing; uncached ones are
|
||||
* fetched a few at a time.
|
||||
*
|
||||
* Never throws. A leg that could not be routed is absent from the result and
|
||||
* the caller draws its straight line, which is what it did before.
|
||||
*/
|
||||
roads: async (legs: readonly Leg[], signal?: AbortSignal): Promise<Map<string, RoadPath>> => {
|
||||
const out = new Map<string, RoadPath>();
|
||||
const wanted: Leg[] = [];
|
||||
|
||||
for (const leg of legs) {
|
||||
const key = keyOf(leg);
|
||||
if (cache.has(key)) {
|
||||
const hit = cache.get(key);
|
||||
if (hit) out.set(key, hit);
|
||||
} else if (wanted.length < MAX_LEGS_PER_DRAW) {
|
||||
wanted.push(leg);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < wanted.length; i += CONCURRENCY) {
|
||||
if (signal?.aborted) break;
|
||||
const batch = wanted.slice(i, i + CONCURRENCY);
|
||||
const paths = await Promise.all(batch.map((leg) => fetchLeg(leg, signal)));
|
||||
batch.forEach((leg, index) => {
|
||||
const key = keyOf(leg);
|
||||
const path = paths[index] ?? null;
|
||||
// Null is cached too: a leg the router cannot do will not start working
|
||||
// if it is asked sixty more times this session.
|
||||
cache.set(key, path);
|
||||
if (path) out.set(key, path);
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
/** The key a leg's path is stored under, for callers reading the result. */
|
||||
keyFor: keyOf,
|
||||
};
|
||||
112
src/api/telemetry.ts
Normal file
112
src/api/telemetry.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Where a rider is right now, and how their phone is doing.
|
||||
*
|
||||
* ── This is a different backend, and that is the whole point ────────────────
|
||||
*
|
||||
* `jupiter.nearle.app` — the platform's older API, which the xpress console
|
||||
* runs against. Fiesta has no equivalent: `getriderperiodiclogs` does not exist
|
||||
* there under any prefix, and `partners/getriderlogs`, the closest-looking
|
||||
* Fiesta endpoint, is a heartbeat log that repeats one fixed coordinate per
|
||||
* rider all day (see `riderShifts`). So this is the ONLY live rider position
|
||||
* on the platform, and it was missed once already by checking Fiesta alone.
|
||||
*
|
||||
* Verified 2026-09-10: rider 852 polled thirty seconds apart moved about 140 m,
|
||||
* with battery, connection and accuracy all changing. Genuinely live.
|
||||
*
|
||||
* Same database as Fiesta underneath — `getallriders` on jupiter and
|
||||
* `getriders` on Fiesta return identical rosters and identical on-duty state —
|
||||
* so a userid from one is a userid in the other.
|
||||
*
|
||||
* ── One rider per call ──────────────────────────────────────────────────────
|
||||
*
|
||||
* There is no fleet form: `?userid=N` answers for that rider, and omitting it
|
||||
* answers for whichever rider reported most recently, which is not useful. A
|
||||
* fleet view therefore fans out, which is fine at the sizes involved — a
|
||||
* merchant's round is a handful of riders.
|
||||
*
|
||||
* ── Freshness is the caller's problem, and must be shown ────────────────────
|
||||
*
|
||||
* The endpoint always answers, and it answers with the LAST known fix however
|
||||
* old. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12 days
|
||||
* ago and nothing in the payload flags them as stale. A map that draws those
|
||||
* next to a live one is lying, so `logdate` is parsed here and every consumer
|
||||
* is handed an age rather than a bare position.
|
||||
*/
|
||||
|
||||
const JUPITER_BASE = (
|
||||
import.meta.env?.['VITE_JUPITER_BASE'] ?? 'https://jupiter.nearle.app'
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
/** One rider's live snapshot, exactly as jupiter sends it. */
|
||||
export interface RiderSnapshot {
|
||||
userid: number;
|
||||
username?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
/** `2026-09-10 11:25:11` — server local time, no zone. */
|
||||
logdate?: string;
|
||||
/** `"95%"`, with the sign. */
|
||||
battery?: string;
|
||||
/** `mobile`, `wifi`, `none`. */
|
||||
connection?: string;
|
||||
/** Metres of GPS uncertainty, as a string. 100.0 is a poor fix. */
|
||||
accuracy?: string;
|
||||
/** Km/h and degrees, both as strings. */
|
||||
speed?: string;
|
||||
heading?: string;
|
||||
/** `idle`, `active`, and whatever else the app decides to send. */
|
||||
status?: string;
|
||||
/** The order they are on, when they are on one. */
|
||||
orderid?: string;
|
||||
is_charging?: boolean;
|
||||
is_background?: boolean;
|
||||
/** `enabled` / `disabled` — a disabled one explains a stale position. */
|
||||
location_service?: string;
|
||||
}
|
||||
|
||||
export class TelemetryError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TelemetryError';
|
||||
}
|
||||
}
|
||||
|
||||
export const telemetryApi = {
|
||||
/**
|
||||
* One rider's latest reported position and phone state.
|
||||
*
|
||||
* Never throws for "this rider has never reported" — that answers 200 with an
|
||||
* empty-ish body, and the caller wants to draw the rider as unreachable
|
||||
* rather than show an error. It throws only when jupiter itself cannot be
|
||||
* reached, which is a different thing and worth saying out loud: jupiter can
|
||||
* be down while Fiesta is fine, and vice versa.
|
||||
*/
|
||||
rider: async (userid: number, signal?: AbortSignal): Promise<RiderSnapshot | null> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
`${JUPITER_BASE}/live/api/v1/utils/getriderperiodiclogs?userid=${userid}`,
|
||||
{ headers: { Accept: 'application/json' }, signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') throw error;
|
||||
throw new TelemetryError(
|
||||
'Could not reach the live rider service. It is a separate backend from the rest of the console, so everything else keeps working.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TelemetryError(`The live rider service answered ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { data?: RiderSnapshot }
|
||||
| null;
|
||||
const data = payload?.data;
|
||||
// A rider who has never opened the app comes back without coordinates.
|
||||
// Null rather than an empty object, so "no fix" is one check everywhere.
|
||||
return data && (data.latitude || data.longitude) ? data : null;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user