dispatch page
This commit is contained in:
@@ -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
89
scripts/checkOptimiser.ts
Normal 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
135
src/api/optimiser.ts
Normal 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 }),
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
350
src/features/store-admin/RoutePlanDrawer.tsx
Normal file
350
src/features/store-admin/RoutePlanDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
201
src/features/store-admin/routePlan.test.ts
Normal file
201
src/features/store-admin/routePlan.test.ts
Normal 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);
|
||||
});
|
||||
230
src/features/store-admin/routePlan.ts
Normal file
230
src/features/store-admin/routePlan.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user