231 lines
8.2 KiB
TypeScript
231 lines
8.2 KiB
TypeScript
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));
|
|
}
|