diff --git a/package.json b/package.json index 11cbb65..68fe634 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/checkOptimiser.ts b/scripts/checkOptimiser.ts new file mode 100644 index 0000000..f4c9083 --- /dev/null +++ b/scripts/checkOptimiser.ts @@ -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(); diff --git a/src/api/optimiser.ts b/src/api/optimiser.ts new file mode 100644 index 0000000..7750b28 --- /dev/null +++ b/src/api/optimiser.ts @@ -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(path: string, body: unknown): Promise { + 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('/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 }), +}; diff --git a/src/features/store-admin/AssignBar.tsx b/src/features/store-admin/AssignBar.tsx index 51e52ab..37fe16e 100644 --- a/src/features/store-admin/AssignBar.tsx +++ b/src/features/store-admin/AssignBar.tsx @@ -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(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 /> + {/* 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. */} + + + + + ); +} diff --git a/src/features/store-admin/pages/deliveries.css b/src/features/store-admin/pages/deliveries.css index cfca904..0aeeea8 100644 --- a/src/features/store-admin/pages/deliveries.css +++ b/src/features/store-admin/pages/deliveries.css @@ -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; } diff --git a/src/features/store-admin/routePlan.test.ts b/src/features/store-admin/routePlan.test.ts new file mode 100644 index 0000000..de730b3 --- /dev/null +++ b/src/features/store-admin/routePlan.test.ts @@ -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 => + ({ + 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); +}); diff --git a/src/features/store-admin/routePlan.ts b/src/features/store-admin/routePlan.ts new file mode 100644 index 0000000..c224e0b --- /dev/null +++ b/src/features/store-admin/routePlan.ts @@ -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; +} + +/** 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(); + 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)); +}