379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
import {
|
|
assignmentSummary,
|
|
extractRiders,
|
|
extractUnassigned,
|
|
moveOrderInPreviewData,
|
|
withUnassignedZones,
|
|
UNASSIGNED_RIDER_ID
|
|
} from '@/lib/dispatchPreview';
|
|
|
|
/**
|
|
* The orders the optimiser refuses, and how the console reports them.
|
|
*
|
|
* The bug this covers: pressing "AI assign" produced a green "Orders
|
|
* optimised" toast over an empty preview board. The optimiser had not
|
|
* failed silently — it answered 200 with every order in
|
|
* `zones[].unassigned_orders[]`, each carrying an `unassigned_reason` — and
|
|
* nothing in the console read that key. `grep unassigned Preview.jsx`
|
|
* returned zero matches.
|
|
*
|
|
* REAL_RESPONSE below is not invented. It is the live reply from
|
|
* routes.workolik.com on 2026-09-22 to a single Coimbatore delivery, trimmed
|
|
* to the fields under test. That matters: the whole defect was a shape
|
|
* assumption, so a fixture written from the same assumption would pass while
|
|
* the page stayed broken.
|
|
*/
|
|
|
|
const REAL_RESPONSE = {
|
|
code: 200,
|
|
status: true,
|
|
zone_summary: [
|
|
{
|
|
zone_name: 'North East',
|
|
total_orders: 1,
|
|
assigned_orders: 0,
|
|
unassigned_orders_count: 1,
|
|
active_riders_count: 0
|
|
}
|
|
],
|
|
zones: [
|
|
{
|
|
zone_name: 'North East',
|
|
total_orders: 1,
|
|
active_riders_count: 0,
|
|
assigned_orders: 0,
|
|
unassigned_orders_count: 1,
|
|
riders: [],
|
|
unassigned_orders: [
|
|
{
|
|
deliveryid: 0,
|
|
bookingid: 901,
|
|
bookingno: 'DM-TEST-1',
|
|
orderstatus: 'pending',
|
|
deliverycharges: 120,
|
|
deliverylocation: 'Gandhipuram, Coimbatore',
|
|
unassigned_reason: 'No riders found (check partner online status).'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
};
|
|
|
|
/** The other real shape: empty input gives `details: {}` — an object. */
|
|
const EMPTY_INPUT_RESPONSE = {
|
|
code: 200,
|
|
status: true,
|
|
details: {},
|
|
message: 'No orders found to assign.',
|
|
meta: { active_riders_count: 0 }
|
|
};
|
|
|
|
const assignedZone = (orders) => ({
|
|
zones: [{ zone_name: 'North East', riders: [{ rider_id: 7, rider_name: 'Rajan A', orders }] }]
|
|
});
|
|
|
|
describe('extractUnassigned', () => {
|
|
it('finds the orders the live optimiser refused', () => {
|
|
const rows = extractUnassigned(REAL_RESPONSE);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].bookingno).toBe('DM-TEST-1');
|
|
});
|
|
|
|
it('keeps the reason, which is the whole point', () => {
|
|
expect(extractUnassigned(REAL_RESPONSE)[0].unassigned_reason).toBe(
|
|
'No riders found (check partner online status).'
|
|
);
|
|
});
|
|
|
|
it('tags each order with the zone it came from', () => {
|
|
expect(extractUnassigned(REAL_RESPONSE)[0].zone_name).toBe('North East');
|
|
});
|
|
|
|
it('gives every order a stable orderid for the list key', () => {
|
|
expect(extractUnassigned(REAL_RESPONSE)[0].orderid).toBe('DM-TEST-1');
|
|
});
|
|
|
|
it('falls back through the id spellings the services disagree on', () => {
|
|
const rows = extractUnassigned({
|
|
zones: [{ unassigned_orders: [{ booking_id: 55 }, { order_id: 66 }, { id: 77 }] }]
|
|
});
|
|
expect(rows.map((r) => r.orderid)).toEqual(['55', '66', '77']);
|
|
});
|
|
|
|
it('synthesises an id rather than dropping an order that has none', () => {
|
|
// A row with no id at all must still reach the operator; a missing key
|
|
// is not a reason to lose an order they are waiting on.
|
|
const rows = extractUnassigned({ zones: [{ unassigned_orders: [{ deliverylocation: 'Somewhere' }] }] });
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].orderid).toBeTruthy();
|
|
});
|
|
|
|
it('says so when no reason was given, rather than showing blank', () => {
|
|
const rows = extractUnassigned({ zones: [{ unassigned_orders: [{ bookingno: 'X' }] }] });
|
|
expect(rows[0].unassigned_reason).toBe('No reason given');
|
|
});
|
|
|
|
it('accepts the alternative reason spellings', () => {
|
|
const rows = extractUnassigned({
|
|
zones: [{ unassigned_orders: [{ bookingno: 'A', reason: 'r' }, { bookingno: 'B', message: 'm' }] }]
|
|
});
|
|
expect(rows.map((r) => r.unassigned_reason)).toEqual(['r', 'm']);
|
|
});
|
|
|
|
it('reads a top-level list too, not only per-zone', () => {
|
|
// The envelope is demonstrably not fixed — the empty-input reply has no
|
|
// zones at all — and an order in neither place is one nobody hears about.
|
|
const rows = extractUnassigned({ unassigned_orders: [{ bookingno: 'TOP' }] });
|
|
expect(rows.map((r) => r.bookingno)).toEqual(['TOP']);
|
|
});
|
|
|
|
it('collects across several zones and de-duplicates', () => {
|
|
const rows = extractUnassigned({
|
|
zones: [
|
|
{ zone_name: 'A', unassigned_orders: [{ bookingno: 'ONE' }] },
|
|
{ zone_name: 'B', unassigned_orders: [{ bookingno: 'TWO' }, { bookingno: 'ONE' }] }
|
|
]
|
|
});
|
|
expect(rows.map((r) => r.bookingno)).toEqual(['ONE', 'TWO']);
|
|
});
|
|
|
|
it('is empty, not a crash, for the shapes that carry nothing', () => {
|
|
expect(extractUnassigned(null)).toEqual([]);
|
|
expect(extractUnassigned(undefined)).toEqual([]);
|
|
expect(extractUnassigned({})).toEqual([]);
|
|
// `details: {}` is an object here, not an array — the real empty reply.
|
|
expect(extractUnassigned(EMPTY_INPUT_RESPONSE)).toEqual([]);
|
|
expect(extractUnassigned({ zones: [] })).toEqual([]);
|
|
expect(extractUnassigned({ zones: [{ riders: [] }] })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('assignmentSummary', () => {
|
|
it('reports a total refusal as failed — the bug in one assertion', () => {
|
|
const s = assignmentSummary(REAL_RESPONSE);
|
|
expect(s).toMatchObject({ assigned: 0, unassigned: 1, total: 1, riders: 0, failed: true });
|
|
});
|
|
|
|
it('surfaces the reason so the toast can name it', () => {
|
|
expect(assignmentSummary(REAL_RESPONSE).reasons).toEqual([
|
|
{ reason: 'No riders found (check partner online status).', count: 1 }
|
|
]);
|
|
});
|
|
|
|
it('is NOT failed when everything was assigned', () => {
|
|
const s = assignmentSummary(assignedZone([{ bookingno: 'A' }, { bookingno: 'B' }]));
|
|
expect(s).toMatchObject({ assigned: 2, unassigned: 0, riders: 1, failed: false });
|
|
});
|
|
|
|
it('is NOT failed on a partial run — some work did land', () => {
|
|
const preview = {
|
|
zones: [
|
|
{
|
|
zone_name: 'North East',
|
|
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ bookingno: 'A' }] }],
|
|
unassigned_orders: [{ bookingno: 'B', unassigned_reason: 'Out of range' }]
|
|
}
|
|
]
|
|
};
|
|
const s = assignmentSummary(preview);
|
|
expect(s).toMatchObject({ assigned: 1, unassigned: 1, total: 2, failed: false });
|
|
});
|
|
|
|
it('ranks reasons by frequency, so one line explains a run', () => {
|
|
const preview = {
|
|
zones: [
|
|
{
|
|
unassigned_orders: [
|
|
{ bookingno: 'A', unassigned_reason: 'No riders found' },
|
|
{ bookingno: 'B', unassigned_reason: 'Out of range' },
|
|
{ bookingno: 'C', unassigned_reason: 'No riders found' }
|
|
]
|
|
}
|
|
]
|
|
};
|
|
expect(assignmentSummary(preview).reasons).toEqual([
|
|
{ reason: 'No riders found', count: 2 },
|
|
{ reason: 'Out of range', count: 1 }
|
|
]);
|
|
});
|
|
|
|
it('is not failed when there was simply no work — that is not a failure', () => {
|
|
// An empty run must not shout. `failed` means "was given orders and
|
|
// placed none", not "had nothing to do".
|
|
expect(assignmentSummary(EMPTY_INPUT_RESPONSE)).toMatchObject({
|
|
assigned: 0,
|
|
unassigned: 0,
|
|
total: 0,
|
|
failed: false
|
|
});
|
|
expect(assignmentSummary(null)).toMatchObject({ total: 0, failed: false });
|
|
});
|
|
|
|
it('counts orders across multiple riders', () => {
|
|
const preview = {
|
|
zones: [
|
|
{
|
|
riders: [
|
|
{ rider_id: 1, rider_name: 'A', orders: [{ bookingno: 'A1' }, { bookingno: 'A2' }] },
|
|
{ rider_id: 2, rider_name: 'B', orders: [{ bookingno: 'B1' }] }
|
|
]
|
|
}
|
|
]
|
|
};
|
|
expect(assignmentSummary(preview)).toMatchObject({ assigned: 3, riders: 2, failed: false });
|
|
});
|
|
});
|
|
|
|
describe('withUnassignedZones', () => {
|
|
it('puts the refused order on the board, under its own zone', () => {
|
|
const folded = withUnassignedZones(REAL_RESPONSE);
|
|
expect(folded.zones).toHaveLength(1);
|
|
expect(folded.zones[0].zone_name).toBe('North East');
|
|
expect(folded.zones[0].riders).toHaveLength(1);
|
|
expect(folded.zones[0].riders[0].orders).toHaveLength(1);
|
|
});
|
|
|
|
it('files it under the key Dispatch skips, so no miler is invented', () => {
|
|
const bucket = withUnassignedZones(REAL_RESPONSE).zones[0].riders[0];
|
|
expect(bucket.rider_id).toBe(UNASSIGNED_RIDER_ID);
|
|
expect(bucket.rider_name).toBe('Unassigned');
|
|
});
|
|
|
|
it('carries the reason through to the order on the board', () => {
|
|
const order = withUnassignedZones(REAL_RESPONSE).zones[0].riders[0].orders[0];
|
|
expect(order.unassigned_reason).toBe('No riders found (check partner online status).');
|
|
expect(order.orderid).toBe('DM-TEST-1');
|
|
});
|
|
|
|
it('numbers the orders so the step chips have something to show', () => {
|
|
const orders = withUnassignedZones({
|
|
zones: [{ zone_name: 'Z', unassigned_orders: [{ bookingno: 'A' }, { bookingno: 'B' }] }]
|
|
}).zones[0].riders[0].orders;
|
|
expect(orders.map((o) => o.step)).toEqual([1, 2]);
|
|
expect(orders.every((o) => o.trip_number === 1)).toBe(true);
|
|
});
|
|
|
|
it('keeps the riders that DID get work, alongside the bucket', () => {
|
|
const folded = withUnassignedZones({
|
|
zones: [
|
|
{
|
|
zone_name: 'North East',
|
|
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ bookingno: 'A' }] }],
|
|
unassigned_orders: [{ bookingno: 'B', unassigned_reason: 'Out of range' }]
|
|
}
|
|
]
|
|
});
|
|
expect(folded.zones[0].riders.map((r) => r.rider_id)).toEqual([7, UNASSIGNED_RIDER_ID]);
|
|
});
|
|
|
|
it('creates a zone for a top-level refusal that belongs to none', () => {
|
|
const folded = withUnassignedZones({ unassigned_orders: [{ bookingno: 'TOP' }] });
|
|
expect(folded.zones).toHaveLength(1);
|
|
expect(folded.zones[0].zone_name).toBe('Unzoned');
|
|
});
|
|
|
|
it('is idempotent — folding twice does not double the bucket', () => {
|
|
const once = withUnassignedZones(REAL_RESPONSE);
|
|
const twice = withUnassignedZones(once);
|
|
expect(twice.zones[0].riders).toHaveLength(1);
|
|
expect(twice.zones[0].riders[0].orders).toHaveLength(1);
|
|
});
|
|
|
|
it('returns the input untouched when nothing was refused', () => {
|
|
const clean = assignedZone([{ bookingno: 'A' }]);
|
|
expect(withUnassignedZones(clean)).toBe(clean);
|
|
expect(withUnassignedZones(EMPTY_INPUT_RESPONSE)).toBe(EMPTY_INPUT_RESPONSE);
|
|
expect(withUnassignedZones(null)).toBeNull();
|
|
});
|
|
|
|
it('does not mutate the response it was given', () => {
|
|
const before = JSON.stringify(REAL_RESPONSE);
|
|
withUnassignedZones(REAL_RESPONSE);
|
|
expect(JSON.stringify(REAL_RESPONSE)).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('assigning a rider to an order the optimiser refused', () => {
|
|
/**
|
|
* This is the recovery path the board now offers. Before the refused orders
|
|
* were drawn, the "change rider" button was unreachable for them; drawing
|
|
* them without this would put a button on screen that silently did nothing.
|
|
*/
|
|
const move = (preview) =>
|
|
moveOrderInPreviewData(preview, { orderId: 'DM-TEST-1', newRiderId: 7, newRiderName: 'Rajan A' });
|
|
|
|
it('takes the order out of the refusal list', () => {
|
|
const moved = move(REAL_RESPONSE);
|
|
expect(moved.zones[0].unassigned_orders).toHaveLength(0);
|
|
expect(extractUnassigned(moved)).toEqual([]);
|
|
});
|
|
|
|
it('puts it on the chosen rider, in the zone it came from', () => {
|
|
const moved = move(REAL_RESPONSE);
|
|
const rider = moved.zones[0].riders.find((r) => r.rider_id === 7);
|
|
expect(rider.orders.map((o) => o.bookingno)).toEqual(['DM-TEST-1']);
|
|
expect(moved.zones[0].zone_name).toBe('North East');
|
|
});
|
|
|
|
it('stamps the orderid the board keyed it by, so the commit agrees', () => {
|
|
const rider = move(REAL_RESPONSE).zones[0].riders.find((r) => r.rider_id === 7);
|
|
expect(rider.orders[0].orderid).toBe('DM-TEST-1');
|
|
});
|
|
|
|
it('turns the run from failed into assigned', () => {
|
|
expect(assignmentSummary(REAL_RESPONSE)).toMatchObject({ assigned: 0, unassigned: 1, failed: true });
|
|
expect(assignmentSummary(move(REAL_RESPONSE))).toMatchObject({ assigned: 1, unassigned: 0, failed: false });
|
|
});
|
|
|
|
it('adds to a rider who already has work rather than duplicating them', () => {
|
|
const preview = {
|
|
zones: [
|
|
{
|
|
zone_name: 'North East',
|
|
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ orderid: 'A', bookingno: 'A' }] }],
|
|
unassigned_orders: [{ bookingno: 'DM-TEST-1' }]
|
|
}
|
|
]
|
|
};
|
|
const moved = move(preview);
|
|
expect(moved.zones[0].riders).toHaveLength(1);
|
|
expect(moved.zones[0].riders[0].orders.map((o) => o.orderid)).toEqual(['A', 'DM-TEST-1']);
|
|
});
|
|
|
|
it('handles a zone that holds refusals and no riders array at all', () => {
|
|
const moved = move({ zones: [{ zone_name: 'North East', unassigned_orders: [{ bookingno: 'DM-TEST-1' }] }] });
|
|
expect(moved.zones[0].riders[0].rider_id).toBe(7);
|
|
expect(moved.zones[0].unassigned_orders).toHaveLength(0);
|
|
});
|
|
|
|
it('handles a refusal reported at the top level, with no zones', () => {
|
|
const moved = move({ unassigned_orders: [{ bookingno: 'DM-TEST-1' }] });
|
|
expect(moved.zones).toHaveLength(1);
|
|
expect(moved.zones[0].riders[0].orders[0].orderid).toBe('DM-TEST-1');
|
|
expect(moved.unassigned_orders).toHaveLength(0);
|
|
});
|
|
|
|
it('never loses the order — it is on a rider or still refused, never neither', () => {
|
|
[REAL_RESPONSE, { unassigned_orders: [{ bookingno: 'DM-TEST-1' }] }].forEach((preview) => {
|
|
const moved = move(preview);
|
|
const onRider = extractRiders(moved).flatMap((r) => r.orders).length;
|
|
expect(onRider + extractUnassigned(moved).length).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('leaves an order it cannot find alone, rather than moving the wrong one', () => {
|
|
const moved = moveOrderInPreviewData(REAL_RESPONSE, {
|
|
orderId: 'NOT-HERE',
|
|
newRiderId: 7,
|
|
newRiderName: 'Rajan A'
|
|
});
|
|
expect(extractUnassigned(moved)).toHaveLength(1);
|
|
expect(extractRiders(moved)).toEqual([]);
|
|
});
|
|
|
|
it('keeps the synthetic bucket out of the commit payload', () => {
|
|
// Folded data must never reach the payload builder as a rider.
|
|
expect(extractRiders(withUnassignedZones(REAL_RESPONSE))).toEqual([]);
|
|
});
|
|
});
|