dispatch page

This commit is contained in:
2026-09-05 15:01:16 +05:30
parent 37b0833328
commit 5006a4f0d4
8 changed files with 1120 additions and 1 deletions

View File

@@ -13,6 +13,7 @@
"db": "node scripts/db.mjs",
"appgap": "node scripts/appgap.mjs",
"check:health": "tsx scripts/checkHealthPanel.ts",
"check:optimiser": "tsx scripts/checkOptimiser.ts",
"verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"",
"appsweep": "node scripts/appsweep.mjs",
"appfix": "node scripts/appfix.mjs"

89
scripts/checkOptimiser.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* The route-plan chain, run against the live optimiser.
*
* Not a test — the unit tests pin `routePlan.ts` against a frozen fixture. This
* calls the real service with real order rows and pushes the answer through the
* same functions the drawer uses, which is the only way to notice the service
* changing shape under us.
*
* npm run check:optimiser
*/
import { optimiserApi } from '../src/api/optimiser';
import type { OrderRow, RiderInfo } from '../src/api/types';
import {
applyReconcile,
commitProblem,
dirtyRiders,
planFromSequence,
splitRoutable,
planKms,
reorderStops,
unplaced,
} from '../src/features/store-admin/routePlan';
const line = (s = '') => console.log(s);
/** Real Suriya Store geography: the RS Puram branch out to four drops. */
const ORDERS: OrderRow[] = [
{ orderheaderid: 1, orderid: 'N-1', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0284', deliverylong: '77.0120', deliverycustomer: 'Peelamedu' },
{ orderheaderid: 2, orderid: 'N-2', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0050', deliverylong: '76.9508', deliverycustomer: 'RS Puram' },
{ orderheaderid: 3, orderid: 'N-3', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '10.9877', deliverylong: '76.9620', deliverycustomer: 'Ukkadam' },
{ orderheaderid: 4, orderid: 'N-4', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0183', deliverylong: '76.9724', deliverycustomer: 'Gandhipuram' },
// No coordinates — must come back as unplaced rather than vanishing.
{ orderheaderid: 5, orderid: 'N-5', deliverycustomer: 'No location on file' },
] as OrderRow[];
const RIDER: RiderInfo = { userid: 9701, fullname: 'Meera Raj', contactno: '9000000001' };
line('─'.repeat(74));
line('1 · SEQUENCE — send in a deliberately bad order, see what comes back');
line('─'.repeat(74));
const { routable, unroutable } = splitRoutable(ORDERS);
const stops = await optimiserApi.sequence(routable);
let plan = planFromSequence(stops, RIDER);
for (const stop of plan.riders[0]?.orders ?? []) {
line(
` ${String(stop.step).padStart(2)} ${(stop.orderid ?? '').padEnd(5)} ` +
`${(stop.deliverycustomer ?? '').padEnd(22)} ` +
`${String(stop.previouskms ?? '').padStart(5)} km ` +
`cum ${String(stop.cumulativekms ?? '').padStart(5)} km ` +
`eta ${stop.eta ?? '-'}m actualkms ${stop.actualkms ?? '-'}`,
);
}
line(` total ${planKms(plan).toFixed(1)} km`);
const missed = [...unroutable, ...unplaced(routable, stops)];
line(` unplaced: ${missed.length ? missed.map((o) => o.orderid).join(', ') : 'none'}`);
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
line();
line('─'.repeat(74));
line('2 · EDIT — move the first stop to last, which breaks the step numbers');
line('─'.repeat(74));
plan = reorderStops(plan, RIDER.userid, 0, (plan.riders[0]?.orders.length ?? 1) - 1);
line(` dirty rounds: ${[...plan.dirty].join(', ')}`);
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')} <- out of order`);
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'NO'}`);
line(` reason: ${commitProblem(plan)}`);
line();
line('─'.repeat(74));
line('3 · RECONCILE — the service repairs the step numbers');
line('─'.repeat(74));
try {
const response = await optimiserApi.reconcile(dirtyRiders(plan));
plan = applyReconcile(plan, response);
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')}`);
line(` dirty rounds: ${plan.dirty.size === 0 ? 'none' : [...plan.dirty].join(', ')}`);
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
} catch (error) {
line(` reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
line(` commit still blocked? ${commitProblem(plan) !== '' ? 'YES — correct' : 'NO — WRONG'}`);
}
line();

135
src/api/optimiser.ts Normal file
View File

@@ -0,0 +1,135 @@
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.
*
* ── What we deliberately do not call ────────────────────────────────────────
*
* `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.
*/
const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1';
/** 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;
}
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 }),
};

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector';
import { UserCheck } from 'lucide-react';
import { Route, UserCheck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
RIDER_MESSAGE,
@@ -13,6 +13,7 @@ import type { OrderRow, TenantLocation } from '@/api/types';
import { useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
import { RoutePlanDrawer } from './RoutePlanDrawer';
/**
* Assigning riders, from the orders table itself.
@@ -40,6 +41,7 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
const client = useQueryClient();
const [riderId, setRiderId] = useState('');
const [outcome, setOutcome] = useState<string | null>(null);
const [planning, setPlanning] = useState(false);
// Every order in a batch shares a region in practice — they are one branch's
// orders — so the first one carrying a region decides which fleet to ask for.
@@ -136,6 +138,20 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
/>
</div>
{/* Two ways to commit the same selection, and the difference is worth the
second button: "Assign" hands the orders over as they are, which is
right for one or two. "Plan the route" sequences them first — real
road distances and an ETA per leg — which starts paying for itself at
about four stops and is the only path that fills the delivery rows'
kms fields with anything real. */}
<Button
label="Plan the route"
variant="secondary"
size="sm"
icon={<Route size={14} />}
isDisabled={!rider || orders.length === 0}
onClick={() => setPlanning(true)}
/>
<Button
label={send.isPending ? 'Assigning…' : 'Assign rider'}
variant="primary"
@@ -146,6 +162,17 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
/>
<Button label="Clear" variant="ghost" size="sm" onClick={onClear} />
{planning && rider ? (
<RoutePlanDrawer
orders={orders}
rider={rider}
branchOf={branchOf}
assigned={assigned}
onClose={() => setPlanning(false)}
onDone={onDone}
/>
) : null}
{outcome ? (
<span className="assign-bar-outcome" role="status">
{outcome}

View File

@@ -0,0 +1,350 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, ArrowDown, ArrowUp, Bike, Route, Wand2 } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { RIDER_MESSAGE, RiderNotReachableError, deliveriesApi } from '@/api/deliveries';
import { optimiserApi, type SequencedStop } from '@/api/optimiser';
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { buildDeliveries, riderName } from './assignDelivery';
import { money } from './format';
import { orderValue } from './orderStatus';
import {
applyReconcile,
commitProblem,
dirtyRiders,
planFromSequence,
planKms,
reorderStops,
splitRoutable,
unplaced,
type Plan,
} from './routePlan';
import './pages/deliveries.css';
/**
* Planning a round before committing it.
*
* Three steps, in an order the operator cannot get wrong:
*
* 1. The optimiser sequences the stops — nearest-neighbour over real roads,
* with a distance and an ETA per leg.
* 2. The operator may reorder them. Any edit marks the round unreconciled.
* 3. Reconcile repairs the step numbers, and only then does Assign unlock.
*
* ── Why the gate is in code and not in a note ───────────────────────────────
*
* Committing an edited plan without reconciling corrupts route sequences in the
* database, and nothing at the point of the write can detect it — the delivery
* rows look fine and the steps are simply wrong. The team whose page this
* pattern comes from call it the single biggest production bug in the area, in
* a file that also says the rule was broken repeatedly. A rule that has already
* been forgotten by the people who wrote it down belongs in the button, not in
* the prose.
*
* ── Scope ───────────────────────────────────────────────────────────────────
*
* One rider per plan, because that is what the assign flow produces. Moving a
* stop BETWEEN riders is written and tested in `routePlan.ts` and not offered
* here: it only means something once several riders are planned at once, which
* needs auto-assignment — and that is blocked on the optimiser assigning
* against its own fleet rather than ours.
*/
export interface RoutePlanDrawerProps {
orders: OrderRow[];
rider: RiderInfo;
branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>;
onClose: () => void;
onDone: () => void;
}
export function RoutePlanDrawer({
orders,
rider,
branchOf,
assigned,
onClose,
onDone,
}: RoutePlanDrawerProps) {
const client = useQueryClient();
const [plan, setPlan] = useState<Plan | null>(null);
const [skipped, setSkipped] = useState<OrderRow[]>([]);
const [problem, setProblem] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
/*
* Only orders that CAN be routed are sent.
*
* The optimiser does not refuse one without coordinates — it reads a missing
* latitude as 0 and routes to the Gulf of Guinea. Verified live: a single
* such order turned a 17 km round into 8,601 km, and nothing in the response
* marked that stop as different from the rest.
*/
const { routable, unroutable } = splitRoutable(orders);
const sequence = useMutation({
mutationFn: () => optimiserApi.sequence(routable),
onSuccess: (stops) => {
setPlan(planFromSequence(stops, rider));
// Two ways to fall out of a plan: refused before sending for want of a
// location, or dropped by the service. Both are real work and both are
// named rather than silently lost.
setSkipped([...unroutable, ...unplaced(routable, stops)]);
setProblem(null);
},
onError: (error) => setProblem(errorMessage(error)),
});
// Sequence once, on open. The operator picked the orders and the rider
// already; making them press a button to see the plan is a step for nothing.
useEffect(() => {
sequence.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reconcile = useMutation({
mutationFn: () => optimiserApi.reconcile(dirtyRiders(plan as Plan)),
onSuccess: (response) => {
setPlan((prev) => (prev ? applyReconcile(prev, response) : prev));
setProblem(null);
},
onError: (error) => setProblem(errorMessage(error)),
});
const commit = useMutation({
mutationFn: async () => {
if (!plan) throw new Error('No plan');
const stop = commitProblem(plan);
if (stop) throw new Error(stop);
// The sequenced rows carry step, kms and actualkms from the optimiser.
// They are ordinary order fields by the time `buildDeliveries` sees them,
// so the routing data rides along into the delivery rows — which is what
// fills `deliveries.kms` / `actualkms` with real numbers instead of the
// literal "null" found in production.
const ordered = plan.riders.flatMap((r) => r.orders) as OrderRow[];
const { drafts } = buildDeliveries(ordered, rider, branchOf, new Date(), assigned);
if (drafts.length === 0) throw new Error('None of these orders can be assigned');
await deliveriesApi.assign(drafts);
return drafts.length;
},
onSuccess: async (count) => {
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
onDone();
const who = riderName(rider);
setOutcome(`${count} stop${count === 1 ? '' : 's'} assigned to ${who} · telling them…`);
try {
await deliveriesApi.notify(rider.userfcmtoken ?? '', RIDER_MESSAGE.assigned(count));
setOutcome(`${count} assigned to ${who} · rider notified`);
} catch (error) {
setOutcome(
`${count} assigned to ${who} · NOT notified — ${
error instanceof RiderNotReachableError
? 'no device registered'
: 'the push failed, tell them another way'
}`,
);
}
},
onError: (error) => setProblem(errorMessage(error)),
});
const stops = plan?.riders[0]?.orders ?? [];
const blocked = plan ? commitProblem(plan) : 'Planning…';
const isDirty = (plan?.dirty.size ?? 0) > 0;
return (
<Drawer
title="Plan the round"
subtitle={`${riderName(rider)} · ${orders.length} order${orders.length === 1 ? '' : 's'}`}
width={520}
onClose={onClose}
>
<VStack gap={3}>
{sequence.isPending ? (
<Text type="body" size="sm" color="secondary">
Working out the best order…
</Text>
) : null}
{stops.length > 0 ? (
<>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="xsm" color="secondary">
The route
</Text>
<Text type="body" size="xsm" color="secondary">
{planKms(plan as Plan).toFixed(1)} km total
</Text>
</HStack>
<div className="stop-list">
{stops.map((stop, index) => (
<StopRow
key={stop.orderheaderid}
stop={stop}
index={index}
isFirst={index === 0}
isLast={index === stops.length - 1}
onMove={(to) =>
setPlan((prev) =>
prev ? reorderStops(prev, rider.userid, index, to) : prev,
)
}
/>
))}
</div>
{/* The gate, stated before the button rather than after pressing it. */}
{isDirty ? (
<div className="plan-gate">
<HStack gap={1} align="start">
<AlertTriangle size={15} />
<VStack gap={0.5}>
<Text type="body" size="sm">
You changed the order of these stops.
</Text>
<Text type="body" size="xsm" color="secondary">
The step numbers have to be repaired before this can be assigned — committing
now would leave gaps that nothing downstream can see.
</Text>
</VStack>
</HStack>
<Button
label={reconcile.isPending ? 'Reconciling…' : 'Reconcile'}
variant="primary"
size="sm"
icon={<Wand2 size={14} />}
isDisabled={reconcile.isPending}
onClick={() => reconcile.mutate()}
/>
</div>
) : null}
</>
) : null}
{skipped.length > 0 ? (
<div className="assign-held">
<HStack gap={1} align="start">
<AlertTriangle size={15} />
<VStack gap={0.5}>
<Text type="body" size="sm">
{skipped.length === 1
? 'One order could not be placed on the route'
: `${skipped.length} orders could not be placed on the route`}
</Text>
<Text type="body" size="xsm" color="secondary">
Usually a missing delivery location. They are not in this plan and still need a
rider: {skipped.map((o) => o.orderid || `#${o.orderheaderid}`).join(', ')}
</Text>
</VStack>
</HStack>
</div>
) : null}
{problem ? (
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
) : null}
{outcome ? (
<Text type="body" size="sm" role="status">
{outcome}
</Text>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
<HStack gap={1} align="center">
<Button
label="Re-plan"
variant="secondary"
size="sm"
icon={<Route size={14} />}
isDisabled={sequence.isPending}
onClick={() => sequence.mutate()}
/>
<Button
label={commit.isPending ? 'Assigning…' : 'Assign this round'}
variant="primary"
size="sm"
icon={<Bike size={14} />}
isDisabled={Boolean(blocked) || commit.isPending}
onClick={() => commit.mutate()}
/>
</HStack>
</HStack>
{blocked && !sequence.isPending ? (
<Text type="body" size="xsm" color="secondary">
{blocked}
</Text>
) : null}
</VStack>
</Drawer>
);
}
/**
* One stop.
*
* Distance and time are per leg, not cumulative — "2.4 km from the last stop"
* is what tells an operator whether the order is sensible. The running total
* sits once at the top of the list.
*/
function StopRow({
stop,
index,
isFirst,
isLast,
onMove,
}: {
stop: SequencedStop;
index: number;
isFirst: boolean;
isLast: boolean;
onMove: (to: number) => void;
}) {
return (
<div className="stop-row">
<span className="stop-step">{stop.step ?? index + 1}</span>
<div className="stop-main">
<strong>{stop.deliverycustomer || stop.orderid || `#${stop.orderheaderid}`}</strong>
<span>{stop.deliveryaddress || stop.deliverysuburb || 'No address'}</span>
</div>
<div className="stop-side">
<strong>{money(orderValue(stop))}</strong>
<span>
{stop.previouskms != null ? `${stop.previouskms} km` : ''}
{stop.eta ? ` · ${stop.eta} min` : ''}
</span>
</div>
<div className="stop-moves">
<button
type="button"
aria-label={`Move ${stop.orderid || stop.orderheaderid} earlier`}
disabled={isFirst}
onClick={() => onMove(index - 1)}
>
<ArrowUp size={13} />
</button>
<button
type="button"
aria-label={`Move ${stop.orderid || stop.orderheaderid} later`}
disabled={isLast}
onClick={() => onMove(index + 1)}
>
<ArrowDown size={13} />
</button>
</div>
</div>
);
}

View File

@@ -250,3 +250,89 @@
color: var(--color-ink-2);
}
.health-allergens[data-tone='unknown'] svg { color: var(--color-ink-3); }
/* ── Route plan ─────────────────────────────────────────────────────────── */
/* A numbered list, because a round IS a sequence — the number is the whole
point, not decoration. Distance and time are per LEG: "2.4 km from the last
stop" is what tells an operator whether the order makes sense. The running
total sits once at the top rather than on every row. */
.stop-list {
display: flex;
flex-direction: column;
border: 1px solid var(--color-line);
border-radius: 10px;
overflow: hidden;
}
.stop-row {
display: grid;
grid-template-columns: 26px minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 9px 12px;
border-bottom: 1px solid var(--color-line);
}
.stop-row:last-child { border-bottom: none; }
.stop-step {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 6px;
background: var(--color-brand-tint);
color: var(--color-brand);
font: 600 11.5px/1 var(--font-sans);
font-variant-numeric: tabular-nums;
}
.stop-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.stop-main strong { font: 600 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
.stop-main span {
font: 400 11.5px/1.35 var(--font-sans);
color: var(--color-ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stop-side { display: flex; flex-direction: column; gap: 1px; text-align: right; }
.stop-side strong {
font: 600 12.5px/1.3 var(--font-sans);
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
}
.stop-side span {
font: 400 11px/1.3 var(--font-sans);
color: var(--color-ink-3);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.stop-moves { display: flex; flex-direction: column; gap: 2px; }
.stop-moves button {
display: grid;
place-items: center;
width: 22px;
height: 17px;
padding: 0;
border: 1px solid var(--color-line);
border-radius: 5px;
background: var(--color-surface);
color: var(--color-ink-3);
cursor: pointer;
}
.stop-moves button:hover:not(:disabled) { background: var(--color-surface-sunken); color: var(--color-brand); }
.stop-moves button:disabled { opacity: .35; cursor: not-allowed; }
.stop-moves button:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* The reconcile gate. Amber, not red: nothing has broken — the operator made a
deliberate edit and there is one step left before it can be committed. */
.plan-gate {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid #f2d9a8;
border-radius: 10px;
background: #fdf6e8;
color: #8a5a00;
}
.plan-gate svg { flex: none; margin-top: 2px; }

View File

@@ -0,0 +1,201 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { SequencedStop } from '@/api/optimiser';
import {
allStops,
applyReconcile,
commitProblem,
dirtyRiders,
moveStop,
planKms,
reorderStops,
isRoutable,
splitRoutable,
unplaced,
type Plan,
} from './routePlan';
/*
The gate these tests exist for: an unreconciled edit corrupts route sequences in
the database, and nothing at the point of the write can tell. The team who built
the page this pattern came from call it "the single biggest production bug to
avoid in this area", so it is enforced in code rather than in a comment.
Fixture shape is the optimiser's real response, verified live 5 Sep 2026.
*/
const stop = (id: number, step: number, over: Partial<SequencedStop> = {}): SequencedStop =>
({
orderheaderid: id,
orderid: `T-${id}`,
step,
previouskms: 1,
cumulativekms: step,
actualkms: '1.23',
kms: '1',
eta: '11',
...over,
}) as SequencedStop;
const plan = (): Plan => ({
riders: [
{ rider_id: 9701, rider_name: 'Meera Raj', orders: [stop(1, 1), stop(2, 2), stop(3, 3)] },
{ rider_id: 9702, rider_name: 'Kavya S', orders: [stop(4, 1), stop(5, 2)] },
],
dirty: new Set(),
});
/* ── Moving a stop ───────────────────────────────────────────────────────── */
test('moving a stop dirties BOTH riders, not just the destination', () => {
/*
The donor is the one people forget. It is left with a hole in its sequence —
1,2,3,5,6 — and a plan reconciled on the recipient alone commits that hole.
*/
const next = moveStop(plan(), 2, 9702);
assert.deepEqual([...next.dirty].sort(), ['9701', '9702']);
});
test('the stop actually moves, once', () => {
const next = moveStop(plan(), 2, 9702);
assert.deepEqual(next.riders[0]?.orders.map((o) => o.orderheaderid), [1, 3]);
assert.deepEqual(next.riders[1]?.orders.map((o) => o.orderheaderid), [4, 5, 2]);
assert.equal(allStops(next).length, 5, 'no stop is duplicated or lost');
});
test('moving a stop to the rider it is already on changes nothing', () => {
const before = plan();
const after = moveStop(before, 2, 9701);
assert.deepEqual(after, before);
});
test('moving an order that is not in the plan changes nothing', () => {
const before = plan();
assert.deepEqual(moveStop(before, 999, 9702), before);
});
/* ── Reordering ──────────────────────────────────────────────────────────── */
test('reordering dirties only that rider', () => {
const next = reorderStops(plan(), 9701, 0, 2);
assert.deepEqual(next.riders[0]?.orders.map((o) => o.orderheaderid), [2, 3, 1]);
assert.deepEqual([...next.dirty], ['9701']);
assert.deepEqual(next.riders[1]?.orders.map((o) => o.orderheaderid), [4, 5], 'other rider untouched');
});
test('an out-of-range reorder is refused rather than dropping a stop', () => {
const next = reorderStops(plan(), 9701, 0, 99);
assert.deepEqual(next.riders[0]?.orders.map((o) => o.orderheaderid), [1, 2, 3]);
});
/* ── The gate ────────────────────────────────────────────────────────────── */
test('a clean plan may be committed', () => {
assert.equal(commitProblem(plan()), '');
});
test('an edited plan may NOT be committed until it is reconciled', () => {
const edited = moveStop(plan(), 2, 9702);
assert.match(commitProblem(edited), /reconcile/i);
assert.match(commitProblem(edited), /step numbers/i, 'says why, not just no');
});
test('an empty plan may not be committed', () => {
assert.match(commitProblem({ riders: [], dirty: new Set() }), /nothing to assign/i);
});
test('the same order on two rounds is refused', () => {
// Belt and braces: committed twice it becomes two deliveries for one order,
// which nothing downstream would notice.
const broken: Plan = {
riders: [
{ rider_id: 1, orders: [stop(1, 1)] },
{ rider_id: 2, orders: [stop(1, 1)] },
],
dirty: new Set(),
};
assert.match(commitProblem(broken), /two rounds/i);
});
/* ── Reconcile ───────────────────────────────────────────────────────────── */
test('only the edited riders are sent to reconcile', () => {
const edited = reorderStops(plan(), 9701, 0, 1);
const sent = dirtyRiders(edited);
assert.equal(sent.length, 1);
assert.equal(String(sent[0]?.rider_id), '9701');
});
test('the response replaces those riders and clears only their dirty marks', () => {
const edited = moveStop(plan(), 2, 9702);
const fixed = applyReconcile(edited, {
riders: [{ rider_id: 9701, orders: [stop(1, 1), stop(3, 2)] }],
});
assert.deepEqual(fixed.riders[0]?.orders.map((o) => o.step), [1, 2], 'gap closed');
assert.deepEqual([...fixed.dirty], ['9702'], 'the other rider is still dirty');
assert.match(commitProblem(fixed), /reconcile/i, 'so commit is still blocked');
});
test('a rider absent from the response is left exactly as it was', () => {
/*
An edit made while the request was in flight must not be discarded. The
response is merged, never treated as the whole truth.
*/
const edited = moveStop(plan(), 2, 9702);
const fixed = applyReconcile(edited, { riders: [{ rider_id: 9701, orders: [stop(1, 1)] }] });
assert.deepEqual(fixed.riders[1]?.orders.map((o) => o.orderheaderid), [4, 5, 2]);
});
test('a malformed response is ignored rather than wiping the plan', () => {
const edited = moveStop(plan(), 2, 9702);
assert.deepEqual(applyReconcile(edited, {} as never), edited);
});
/* ── Reporting ───────────────────────────────────────────────────────────── */
test('planned distance is the last stop of each round, summed', () => {
// cumulativekms is a running total, so summing every stop would count the
// same kilometres three times over.
assert.equal(planKms(plan()), 3 + 2);
});
test('orders the optimiser could not place are reported, not lost', () => {
// Usually no coordinates. Silently dropping them loses real work.
const sent = [{ orderheaderid: 1 }, { orderheaderid: 2 }, { orderheaderid: 7 }] as never[];
assert.deepEqual(
unplaced(sent, [stop(1, 1), stop(2, 2)]).map((o) => o.orderheaderid),
[7],
);
});
/* ── What must never reach the optimiser ─────────────────────────────────── */
test('an order with no coordinates is refused before it is sent', () => {
/*
The optimiser does not reject it — it reads a missing latitude as 0 and
routes to the Gulf of Guinea. Measured against the live service: one such
order turned a 17 km round into 8,601 km, with a leg of 8,584 km and an
actualkms of 11,158. Nothing in the response marks that stop as different, so
this is the only place it can be caught.
*/
const orders = [
{ orderheaderid: 1, deliverylat: '11.0050', deliverylong: '76.9508' },
{ orderheaderid: 2 },
{ orderheaderid: 3, deliverylat: '', deliverylong: '' },
{ orderheaderid: 4, deliverylat: '0', deliverylong: '0' },
] as never[];
const { routable, unroutable } = splitRoutable(orders);
assert.deepEqual(routable.map((o) => o.orderheaderid), [1]);
assert.deepEqual(unroutable.map((o) => o.orderheaderid), [2, 3, 4]);
});
test('a zero coordinate is treated as missing, not as a place', () => {
// (0,0) is in the Atlantic. It is what a blank column becomes, never a drop.
assert.equal(isRoutable({ orderheaderid: 1, deliverylat: '0', deliverylong: '76.95' } as never), false);
assert.equal(isRoutable({ orderheaderid: 1, deliverylat: '11.0', deliverylong: '0' } as never), false);
});
test('droplat is accepted as a fallback, since delivery rows use both', () => {
assert.equal(isRoutable({ orderheaderid: 1, droplat: '11.0', droplon: '76.95' } as never), true);
});

View File

@@ -0,0 +1,230 @@
import type { PlannedRider, SequencedStop } from '@/api/optimiser';
import type { OrderRow, RiderInfo } from '@/api/types';
import { riderName } from './assignDelivery';
/**
* A route plan, and the rule that stops it being committed broken.
*
* The optimiser returns a sequence; the operator may then move a stop to
* another rider or reorder one round. Both edits break step numbers — the rider
* who lost a stop is left with a gap (1,2,3,5,6) and the one who gained it has
* a step that collides or is missing — and nothing at the point of the write
* can tell. The fix is to call `reconcile-steps` before committing, and the
* whole point of this module is that the UI cannot forget to.
*
* Pure, so the gate can be tested without a network or a drag.
*/
export interface Plan {
riders: PlannedRider[];
/**
* Riders edited since the last reconcile.
*
* Not a boolean over the whole plan: reconcile takes only the riders that
* changed and returns those, leaving the rest alone. A single flag would
* either re-reconcile everything or lose an edit.
*/
dirty: ReadonlySet<string>;
}
/** Rider ids are compared as strings — they arrive as both, from both services. */
export const key = (id: string | number | undefined | null): string => String(id ?? '');
/** A plan with every stop on one rider, which is what a sequence run gives us. */
export function planFromSequence(stops: SequencedStop[], rider: RiderInfo): Plan {
return {
riders: [
{
rider_id: rider.userid,
rider_name: riderName(rider),
orders: [...stops].sort(byStep),
},
],
dirty: new Set(),
};
}
/** Step order, falling back to the order the service returned. */
export function byStep(a: SequencedStop, b: SequencedStop): number {
const sa = a.step ?? Number.MAX_SAFE_INTEGER;
const sb = b.step ?? Number.MAX_SAFE_INTEGER;
return sa - sb;
}
/**
* Every stop in the plan, flattened.
*
* Used for the commit payload and for counting. Order within a rider is kept.
*/
export function allStops(plan: Plan): SequencedStop[] {
return plan.riders.flatMap((rider) => rider.orders);
}
/**
* Move one stop to another rider.
*
* Both riders become dirty, not just the destination. The donor is the one
* people forget: it is left with a hole in its sequence, and a plan reconciled
* on the recipient alone commits that hole to the database.
*/
export function moveStop(plan: Plan, orderheaderid: number, toRiderId: string | number): Plan {
const to = key(toRiderId);
let moved: SequencedStop | undefined;
let fromRider = '';
const stripped = plan.riders.map((rider) => {
const found = rider.orders.find((order) => order.orderheaderid === orderheaderid);
if (!found || key(rider.rider_id) === to) return rider;
moved = found;
fromRider = key(rider.rider_id);
return { ...rider, orders: rider.orders.filter((o) => o.orderheaderid !== orderheaderid) };
});
if (!moved) return plan;
const riders = stripped.map((rider) =>
key(rider.rider_id) === to ? { ...rider, orders: [...rider.orders, moved as SequencedStop] } : rider,
);
const dirty = new Set(plan.dirty);
if (fromRider) dirty.add(fromRider);
dirty.add(to);
return { riders, dirty };
}
/** Reorder one rider's stops. Only that rider is dirtied. */
export function reorderStops(plan: Plan, riderId: string | number, from: number, to: number): Plan {
const target = key(riderId);
const riders = plan.riders.map((rider) => {
if (key(rider.rider_id) !== target) return rider;
const orders = [...rider.orders];
if (from < 0 || from >= orders.length || to < 0 || to >= orders.length) return rider;
const [lifted] = orders.splice(from, 1);
if (lifted) orders.splice(to, 0, lifted);
return { ...rider, orders };
});
const dirty = new Set(plan.dirty);
dirty.add(target);
return { riders, dirty };
}
/** The riders to send to reconcile — only what changed. */
export function dirtyRiders(plan: Plan): PlannedRider[] {
return plan.riders.filter((rider) => plan.dirty.has(key(rider.rider_id)));
}
/**
* Merge a reconcile response back in.
*
* Replaces the orders of the riders the service returned and leaves every other
* rider exactly as it was, so an edit made while the request was in flight is
* not silently discarded. Only the reconciled riders lose their dirty mark, for
* the same reason.
*/
export function applyReconcile(plan: Plan, response: { riders?: PlannedRider[] }): Plan {
if (!Array.isArray(response?.riders)) return plan;
const byId = new Map(response.riders.map((rider) => [key(rider.rider_id), rider]));
const riders = plan.riders.map((rider) => {
const fixed = byId.get(key(rider.rider_id));
return fixed ? { ...rider, orders: [...fixed.orders].sort(byStep) } : rider;
});
const dirty = new Set(plan.dirty);
for (const id of byId.keys()) dirty.delete(id);
return { riders, dirty };
}
/**
* Whether this plan may be written to the database.
*
* The gate. An unreconciled edit corrupts route sequences, and the corruption
* is invisible at the point of the write — so this is enforced here rather than
* left to whoever is looking at the screen.
*/
export function commitProblem(plan: Plan): string {
if (plan.riders.length === 0 || allStops(plan).length === 0) {
return 'There is nothing to assign.';
}
if (plan.dirty.size > 0) {
const n = plan.dirty.size;
return `${n} round${n === 1 ? ' has' : 's have'} unreconciled changes. Reconcile before assigning — committing now would leave gaps in the step numbers.`;
}
const duplicate = duplicateOrder(plan);
if (duplicate) {
return `Order ${duplicate} appears on two rounds. Reload the plan.`;
}
return '';
}
/**
* An order on two riders at once.
*
* Belt and braces against a merge going wrong: the same order committed twice
* becomes two deliveries for one order, which nothing downstream would notice.
* The console this pattern came from carries the same guard for the same
* reason.
*/
function duplicateOrder(plan: Plan): string | null {
const seen = new Set<number>();
for (const stop of allStops(plan)) {
if (!stop.orderheaderid) continue;
if (seen.has(stop.orderheaderid)) return stop.orderid || String(stop.orderheaderid);
seen.add(stop.orderheaderid);
}
return null;
}
/** Total planned distance, for the summary line. Strings on the wire. */
export function planKms(plan: Plan): number {
return plan.riders.reduce((sum, rider) => {
const last = [...rider.orders].sort(byStep).at(-1);
return sum + Number(last?.cumulativekms ?? 0);
}, 0);
}
/**
* Can this order be put on a route at all?
*
* It needs a drop coordinate. The check has to happen HERE, before the request,
* because the optimiser does not refuse an order without one — it reads a
* missing latitude as 0 and routes to the Gulf of Guinea. Measured against the
* live service: one order with no coordinates turned a 17 km round into
* **8,601 km**, with a leg of 8,584 km and an `actualkms` of 11,158.
*
* Nothing in the response marks that stop as different from the others, so a
* plan built on it looks ordinary and is nonsense. Filtering on the way in is
* the only place this can be caught.
*/
export function isRoutable(order: OrderRow): boolean {
const lat = Number(order.deliverylat ?? order.droplat ?? '');
const lon = Number(order.deliverylong ?? order.droplon ?? '');
return Number.isFinite(lat) && Number.isFinite(lon) && lat !== 0 && lon !== 0;
}
/** The orders worth sending, and the ones to report instead. */
export function splitRoutable(orders: readonly OrderRow[]): {
routable: OrderRow[];
unroutable: OrderRow[];
} {
return {
routable: orders.filter(isRoutable),
unroutable: orders.filter((order) => !isRoutable(order)),
};
}
/**
* Orders that went in and did not come back.
*
* Kept alongside `splitRoutable` rather than replaced by it: that one catches
* what we refuse to send, this catches what the service silently drops. They
* are different failures and both lose real work if unreported.
*/
export function unplaced(sent: readonly OrderRow[], stops: readonly SequencedStop[]): OrderRow[] {
const placed = new Set(stops.map((stop) => stop.orderheaderid));
return sent.filter((order) => !placed.has(order.orderheaderid));
}