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)); }