updats on the dispatch page

This commit is contained in:
2026-09-10 17:57:00 +05:30
parent 79a8f2b234
commit 8b2c4daef8
34 changed files with 6929 additions and 1059 deletions

View File

@@ -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
View 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
View 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;
},
};