updates on the ui changes and new changes as well
This commit is contained in:
22
src/lib/dateRange.js
Normal file
22
src/lib/dateRange.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The DatePicker's value, as a `{ from, to }` range.
|
||||
*
|
||||
* `components/ds/DatePicker` emits three different shapes and a page has to
|
||||
* handle all three: `'YYYY-MM-DD'` for a single day (grid click, Today,
|
||||
* Yesterday, the prev/next steppers), `{ from, to }` for the range presets
|
||||
* (Last 7 Days, This Month), and the string `'all'` for no filter.
|
||||
*
|
||||
* Coercing everything through `dayjs(value).format(...)` looks like a
|
||||
* normalisation and is not: `dayjs({ from, to })` and `dayjs('all')` are both
|
||||
* Invalid Date, so the range presets and All Dates silently became the literal
|
||||
* string "Invalid Date" and every row was filtered out against it.
|
||||
*
|
||||
* An empty bound means unbounded on that side, which is what the API layer's
|
||||
* `inRange` already expects.
|
||||
*/
|
||||
export const toDateRange = (value) => {
|
||||
if (!value || value === 'all') return { from: '', to: '' };
|
||||
if (typeof value === 'string') return { from: value, to: value };
|
||||
if (typeof value !== 'object') return { from: '', to: '' };
|
||||
return { from: value.from || '', to: value.to || '' };
|
||||
};
|
||||
@@ -11,6 +11,15 @@
|
||||
* module-local; the logic is unchanged.
|
||||
*/
|
||||
|
||||
/** The rider key a refused order is filed under. Dispatch's rider map skips
|
||||
* this key explicitly, which is what keeps it out of the miler count. */
|
||||
export const UNASSIGNED_RIDER_ID = 'unassigned';
|
||||
|
||||
// The id an order is known by, whichever spelling the service used. Shared so
|
||||
// that an order the board is showing can always be found again in the list it
|
||||
// came from — the refusal lists carry no `orderid` of their own.
|
||||
const orderKey = (o) => o?.orderid || o?.bookingno || o?.bookingid || o?.order_id || o?.booking_id || o?.id || null;
|
||||
|
||||
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
|
||||
// the Reconcile tab UI and the reconcile-API payload.
|
||||
export const extractRiders = (previewData) => {
|
||||
@@ -24,6 +33,10 @@ export const extractRiders = (previewData) => {
|
||||
const seenOrderIds = new Set();
|
||||
const push = (riderId, riderName, orders) => {
|
||||
if (riderId == null) return;
|
||||
// `withUnassignedZones` files refused orders under a synthetic rider so the
|
||||
// board can draw them. This list becomes the reconcile and commit payload,
|
||||
// and a bucket is not a miler — sending it would post `userid: NaN`.
|
||||
if (String(riderId) === UNASSIGNED_RIDER_ID) return;
|
||||
const key = String(riderId);
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
|
||||
@@ -103,13 +116,43 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
|
||||
}
|
||||
|
||||
// 2) Move within zones[].riders[].orders[]
|
||||
// A reply can carry refusals with no zones at all (the empty-input shape has
|
||||
// no `zones` key). Give it an empty one so the search below still runs —
|
||||
// otherwise the board would offer a rider picker for an order this function
|
||||
// could never find, and report success without moving anything.
|
||||
if (!Array.isArray(next.zones) && Array.isArray(next.unassigned_orders) && next.unassigned_orders.length) {
|
||||
next.zones = [];
|
||||
}
|
||||
if (Array.isArray(next.zones)) {
|
||||
let movedOrder = null;
|
||||
let homeZoneIdx = -1;
|
||||
|
||||
// The order may have no rider at all. The optimiser files the ones it
|
||||
// refused in `unassigned_orders[]`, and picking a miler for one of those
|
||||
// from the board is exactly how an operator recovers a failed run — so
|
||||
// that list has to be searched too, and the order taken out of it, or the
|
||||
// "N unassigned" banner would never fall as they work through them.
|
||||
const takeFromRefused = (list, zi) => {
|
||||
if (!Array.isArray(list)) return false;
|
||||
// An id-less row must not match an id-less lookup — that would move an
|
||||
// arbitrary order instead of the one the operator picked.
|
||||
const ui = list.findIndex((o) => {
|
||||
const k = orderKey(o);
|
||||
return k != null && String(k) === String(orderId);
|
||||
});
|
||||
if (ui === -1) return false;
|
||||
movedOrder = list[ui];
|
||||
list.splice(ui, 1);
|
||||
if (zi != null) homeZoneIdx = zi;
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
|
||||
const zone = next.zones[zi];
|
||||
if (!Array.isArray(zone.riders)) continue;
|
||||
if (!Array.isArray(zone.riders)) {
|
||||
takeFromRefused(zone.unassigned_orders, zi);
|
||||
continue;
|
||||
}
|
||||
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
|
||||
const r = zone.riders[ri];
|
||||
if (!Array.isArray(r.orders)) continue;
|
||||
@@ -128,11 +171,18 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!movedOrder) takeFromRefused(zone.unassigned_orders, zi);
|
||||
}
|
||||
// A refusal the service reported at the top level rather than per zone.
|
||||
if (!movedOrder) takeFromRefused(next.unassigned_orders, null);
|
||||
|
||||
if (movedOrder) {
|
||||
const updated = {
|
||||
...movedOrder,
|
||||
// A refused order carries no `orderid` of its own — it was never in the
|
||||
// rider tree. Stamp it now so every reader downstream, including the
|
||||
// commit payload, keys it the same way the board did.
|
||||
orderid: movedOrder.orderid || orderKey(movedOrder) || orderId,
|
||||
rider_id: newRiderId,
|
||||
userid: newRiderId,
|
||||
rider_name: newRiderName,
|
||||
@@ -151,8 +201,19 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed && homeZoneIdx >= 0) {
|
||||
next.zones[homeZoneIdx].riders.push({
|
||||
if (!placed) {
|
||||
// Land it in the zone it came from. A zone that only ever held
|
||||
// refusals has no `riders` array at all, and an order reported at the
|
||||
// top level has no home zone — neither may end with the order being
|
||||
// dropped on the floor, which is what returning here without placing
|
||||
// it would do.
|
||||
if (homeZoneIdx < 0) {
|
||||
next.zones.push({ zone_name: movedOrder.zone_name || 'Unzoned', riders: [] });
|
||||
homeZoneIdx = next.zones.length - 1;
|
||||
}
|
||||
const home = next.zones[homeZoneIdx];
|
||||
home.riders = home.riders || [];
|
||||
home.riders.push({
|
||||
rider_id: newRiderId,
|
||||
userid: newRiderId,
|
||||
rider_name: newRiderName,
|
||||
@@ -272,3 +333,168 @@ export function computeDeliveryAmounts(list) {
|
||||
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The orders the optimiser could NOT place, and why.
|
||||
*
|
||||
* These were being dropped on the floor. `normalizePreviewData` walks
|
||||
* `zones[].riders[].orders[]` and `details[]`; `extractRiders` returns early
|
||||
* on a null rider id. An order the optimiser refused appears in neither — it
|
||||
* sits in `zones[].unassigned_orders[]`, which nothing in this file read.
|
||||
*
|
||||
* The result was the failure this was written for: the optimiser answers
|
||||
*
|
||||
* { code: 200, status: true,
|
||||
* zones: [{ active_riders_count: 0, riders: [],
|
||||
* unassigned_orders: [{ …the order…,
|
||||
* unassigned_reason: "No riders found (check partner online
|
||||
* status)." }] }] }
|
||||
*
|
||||
* — a complete refusal, carrying its own explanation — and the operator got a
|
||||
* green "Orders optimised" toast over an empty board. The reason was in the
|
||||
* payload the whole time.
|
||||
*
|
||||
* Reads the top level too: the service puts them under each zone today, but
|
||||
* the empty-input response shape (`details: {}`, no zones) shows the envelope
|
||||
* is not fixed, and an order that reaches neither place is an order nobody
|
||||
* ever hears about again.
|
||||
*/
|
||||
export const extractUnassigned = (previewData) => {
|
||||
if (!previewData) return [];
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
|
||||
const take = (orders, zoneName) => {
|
||||
(Array.isArray(orders) ? orders : []).forEach((o, idx) => {
|
||||
const id = String(orderKey(o) || `unassigned-${idx}`);
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
out.push({
|
||||
...o,
|
||||
orderid: id,
|
||||
zone_name: o?.zone_name || zoneName || null,
|
||||
// One phrase, whichever spelling the service used. Never invented: an
|
||||
// order with no stated reason says so, because "no reason given" is
|
||||
// itself worth seeing when it happens to every row.
|
||||
unassigned_reason: o?.unassigned_reason || o?.reason || o?.message || 'No reason given'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
(Array.isArray(previewData.zones) ? previewData.zones : []).forEach((z) =>
|
||||
take(z?.unassigned_orders, z?.zone_name)
|
||||
);
|
||||
take(previewData.unassigned_orders, null);
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* What actually happened, in numbers — for the toast and the banner.
|
||||
*
|
||||
* `code: 200` is not success here. The optimiser returns 200 whether it
|
||||
* placed every order or none, so the caller has to count. Reporting "Orders
|
||||
* optimised" off the HTTP status is what turned a total failure into a green
|
||||
* tick.
|
||||
*/
|
||||
export const assignmentSummary = (previewData) => {
|
||||
const riders = extractRiders(previewData);
|
||||
const unassigned = extractUnassigned(previewData);
|
||||
const assigned = riders.reduce((n, r) => n + (r.orders?.length || 0), 0);
|
||||
|
||||
// Distinct reasons, most common first — one line explains a whole failed
|
||||
// run, and in practice every row carries the same reason.
|
||||
const counts = new Map();
|
||||
unassigned.forEach((o) => counts.set(o.unassigned_reason, (counts.get(o.unassigned_reason) || 0) + 1));
|
||||
const reasons = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([reason, count]) => ({ reason, count }));
|
||||
|
||||
return {
|
||||
assigned,
|
||||
unassigned: unassigned.length,
|
||||
total: assigned + unassigned.length,
|
||||
riders: riders.length,
|
||||
reasons,
|
||||
// True when the optimiser placed nothing at all but was handed work.
|
||||
failed: assigned === 0 && unassigned.length > 0
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Put the refused orders back on the board.
|
||||
*
|
||||
* The board renders `zones[].riders[].orders[]` and nothing else, so a reply
|
||||
* whose orders are all in `unassigned_orders[]` produced an empty screen —
|
||||
* "No orders in Afternoon Batch", zero zones, zero pins — over a response
|
||||
* that named the zone, counted the orders and gave the reason. The operator
|
||||
* was shown less than the optimiser said.
|
||||
*
|
||||
* Each refused order is filed under its zone as an `unassigned` rider bucket.
|
||||
* That is not a new shape: the live-data path in Dispatch already synthesises
|
||||
* exactly this for a row with no rider, and both rider-map loops there skip
|
||||
* the `unassigned` key by name — so folding these in makes the zone, its
|
||||
* order count and its pins appear without ever inventing a miler.
|
||||
*
|
||||
* Returns the input untouched when there is nothing to fold, and is
|
||||
* idempotent: re-folding replaces the bucket rather than appending a second.
|
||||
*/
|
||||
export const withUnassignedZones = (previewData) => {
|
||||
if (!previewData) return previewData;
|
||||
const rows = extractUnassigned(previewData);
|
||||
if (!rows.length) return previewData;
|
||||
|
||||
const byZone = new Map();
|
||||
rows.forEach((o) => {
|
||||
const name = o.zone_name || 'Unzoned';
|
||||
if (!byZone.has(name)) byZone.set(name, []);
|
||||
byZone.get(name).push(o);
|
||||
});
|
||||
|
||||
const zones = (Array.isArray(previewData.zones) ? previewData.zones : []).map((z) => ({ ...z }));
|
||||
|
||||
byZone.forEach((orders, name) => {
|
||||
let zone = zones.find((z) => (z.zone_name || 'Unzoned') === name);
|
||||
if (!zone) {
|
||||
zone = { zone_name: name, riders: [] };
|
||||
zones.push(zone);
|
||||
}
|
||||
const bucket = {
|
||||
rider_id: UNASSIGNED_RIDER_ID,
|
||||
rider_name: 'Unassigned',
|
||||
// step/trip only exist so the order cards and step chips have something
|
||||
// to number by; they carry no routing meaning for an unplaced order.
|
||||
orders: orders.map((o, idx) => ({
|
||||
...o,
|
||||
step: o.step || idx + 1,
|
||||
trip_number: o.trip_number || 1
|
||||
}))
|
||||
};
|
||||
zone.riders = [
|
||||
...(zone.riders || []).filter((r) => String(r.rider_id ?? r.userid) !== UNASSIGNED_RIDER_ID),
|
||||
bucket
|
||||
];
|
||||
});
|
||||
|
||||
return { ...previewData, zones };
|
||||
};
|
||||
|
||||
/**
|
||||
* Does an edit change a route the solver planned?
|
||||
*
|
||||
* Reconcile re-sequences a rider's stops after the operator rearranges them,
|
||||
* and Assign Orders stays blocked until it succeeds. That is right for an
|
||||
* edit inside the solver's plan, and a dead end for an order the solver
|
||||
* REFUSED: such an order was never sequenced, and `reconcile-steps` is the
|
||||
* same external service whose rider pool came back empty — a miler it has
|
||||
* never heard of will not appear in its response, the dirty flag never
|
||||
* clears, and the order can never be dispatched at all.
|
||||
*
|
||||
* Narrow on purpose. False only when the order was in the refusal list AND
|
||||
* the miler chosen has no other stops. Dropping a refused order into an
|
||||
* existing route does change that route's sequence, and still reconciles.
|
||||
*/
|
||||
export const editNeedsReconcile = (before, after, { orderId, newRiderId }) => {
|
||||
const wasRefused = extractUnassigned(before).some((o) => String(o.orderid) === String(orderId));
|
||||
if (!wasRefused) return true;
|
||||
const stops = extractRiders(after).find((r) => String(r.rider_id) === String(newRiderId))?.orders?.length ?? 0;
|
||||
return stops > 1;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user