updates on the orders page ai assign to the specific rider
This commit is contained in:
@@ -426,6 +426,158 @@ export const createAutomationDeliveries = async (variables) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Nagercoil auto-assign ||============================== //
|
||||
|
||||
// Base for the routes solver. Overridable per-environment so a staging build
|
||||
// can point at a staging solver; falls back to the live host the other
|
||||
// optimisation calls in this file already hardcode.
|
||||
const ROUTES_API_BASE = process.env.REACT_APP_ROUTES_URL || 'https://routes.workolik.com/api/v1';
|
||||
|
||||
// Pull the orders list out of whatever envelope the solver returns. The
|
||||
// nagercoil endpoint may answer with a bare array, or wrap the rows under
|
||||
// any of these keys — first array-like wins.
|
||||
const pickOrdersArray = (raw) => {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (!raw || typeof raw !== 'object') return [];
|
||||
return [raw.details, raw.deliveries, raw.orders, raw.assignments, raw.data].find((c) => Array.isArray(c)) || [];
|
||||
};
|
||||
|
||||
// Flatten a zones->riders->orders tree into the flat `details` list that
|
||||
// Preview's Assign payload is built from.
|
||||
const flattenZones = (zones) => {
|
||||
const out = [];
|
||||
(zones || []).forEach((z) => {
|
||||
(z.riders || []).forEach((r) => {
|
||||
(r.orders || []).forEach((o) => {
|
||||
out.push({
|
||||
...o,
|
||||
zone_name: z.zone_name,
|
||||
rider_id: r.rider_id,
|
||||
userid: r.rider_id,
|
||||
rider_name: r.rider_name,
|
||||
rider: r.rider_name
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
// Coerce any assign response into the { zones, zone_summary, details } shape
|
||||
// that Preview.js and the embedded Dispatch map consume. Three inputs are
|
||||
// handled: an already-zoned response (passed through, with `details`
|
||||
// backfilled), a flat list of sequenced orders (grouped into zones by
|
||||
// delivery suburb, then by rider), and an empty/no-work response (returns
|
||||
// empty collections so callers can detect "nothing to assign" without
|
||||
// guessing at the message text).
|
||||
export const normaliseAssignResponse = (raw) => {
|
||||
const base = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
|
||||
if (Array.isArray(raw?.zones) && raw.zones.length) {
|
||||
const details = Array.isArray(raw.details) && raw.details.length ? raw.details : flattenZones(raw.zones);
|
||||
return { ...raw, details };
|
||||
}
|
||||
|
||||
const orders = pickOrdersArray(raw);
|
||||
if (!orders.length) return { ...base, zones: [], zone_summary: [], details: [] };
|
||||
|
||||
// Step assignment happens PER RIDER ACROSS THE WHOLE RESPONSE, before any
|
||||
// zone bucketing. The Nagercoil endpoint routes every order to one rider as
|
||||
// a single sequenced run, and that run frequently spans several delivery
|
||||
// suburbs. Numbering within each zone instead would restart the sequence at
|
||||
// each suburb (1,2 | 1) and misrepresent the solver's actual ordering on the
|
||||
// map pins and route lines — so the global sequence is fixed here and the
|
||||
// zone buckets below simply inherit it.
|
||||
const normOrder = (o) => {
|
||||
const riderId = o.rider_id ?? o.userid ?? null;
|
||||
const riderName = o.rider_name || o.ridername || o.username || (riderId != null ? `Rider ${riderId}` : 'Unassigned');
|
||||
return {
|
||||
...o,
|
||||
rider_id: riderId,
|
||||
userid: riderId,
|
||||
rider_name: riderName,
|
||||
rider: riderName,
|
||||
trip_number: o.trip_number || 1,
|
||||
// `step` drives route ordering and the numbered map pins. Accept the
|
||||
// solver's own naming variants; backfilled just below when absent.
|
||||
step: o.step ?? o.sequence ?? o.stop_number ?? null
|
||||
};
|
||||
};
|
||||
|
||||
const byRider = new Map();
|
||||
orders.map(normOrder).forEach((o) => {
|
||||
const riderKey = String(o.rider_id ?? 'unassigned');
|
||||
if (!byRider.has(riderKey)) byRider.set(riderKey, []);
|
||||
byRider.get(riderKey).push(o);
|
||||
});
|
||||
|
||||
// Sort each rider's full run by the solver's sequence (rows without one keep
|
||||
// their arrival order), then backfill a contiguous 1..N step.
|
||||
const stepped = new Map();
|
||||
byRider.forEach((list, riderKey) => {
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const sA = a.step == null ? Number.MAX_SAFE_INTEGER : Number(a.step);
|
||||
const sB = b.step == null ? Number.MAX_SAFE_INTEGER : Number(b.step);
|
||||
return sA - sB;
|
||||
});
|
||||
stepped.set(riderKey, sorted.map((o, idx) => ({ ...o, step: o.step ?? idx + 1 })));
|
||||
});
|
||||
|
||||
// Now bucket into zones for display. Orders carry their global step through
|
||||
// unchanged, so a rider split across suburbs still reads as one route.
|
||||
const zoneBuckets = new Map();
|
||||
stepped.forEach((list) => {
|
||||
list.forEach((o) => {
|
||||
const zoneName = String(o.zone_name || o.deliverysuburb || o.locationsuburb || 'Nagercoil').trim() || 'Nagercoil';
|
||||
const zoneKey = zoneName.toLowerCase();
|
||||
const riderKey = String(o.rider_id ?? 'unassigned');
|
||||
|
||||
if (!zoneBuckets.has(zoneKey)) zoneBuckets.set(zoneKey, { zone_name: zoneName, riders: new Map() });
|
||||
const zone = zoneBuckets.get(zoneKey);
|
||||
if (!zone.riders.has(riderKey)) {
|
||||
zone.riders.set(riderKey, { rider_id: o.rider_id, rider_name: o.rider_name, orders: [] });
|
||||
}
|
||||
zone.riders.get(riderKey).orders.push(o);
|
||||
});
|
||||
});
|
||||
|
||||
const zones = Array.from(zoneBuckets.values()).map((z) => ({
|
||||
zone_name: z.zone_name,
|
||||
riders: Array.from(z.riders.values()).map((rd) => ({
|
||||
...rd,
|
||||
orders: [...rd.orders].sort((a, b) => Number(a.step) - Number(b.step))
|
||||
}))
|
||||
}));
|
||||
|
||||
const zone_summary = zones.map((z) => {
|
||||
const allOrds = z.riders.flatMap((r) => r.orders);
|
||||
const assigned = allOrds.filter((o) => o.userid || o.rider_id).length;
|
||||
return {
|
||||
zone_name: z.zone_name,
|
||||
total_orders: allOrds.length,
|
||||
assigned_orders: assigned,
|
||||
unassigned_orders_count: allOrds.length - assigned,
|
||||
active_riders_count: z.riders.filter((r) => r.rider_id != null).length,
|
||||
total_delivery_kms: allOrds.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
|
||||
total_profit: allOrds.reduce((s, o) => s + parseFloat(o.profit || 0), 0)
|
||||
};
|
||||
});
|
||||
|
||||
return { ...base, zones, zone_summary, details: flattenZones(zones) };
|
||||
};
|
||||
|
||||
// Nagercoil auto-assign. Every order routes to the hub's single rider,
|
||||
// sequenced with ETAs, server-side. Passing `deliveries` scopes the run to
|
||||
// the rows the operator is looking at; omitting them (empty body) lets the
|
||||
// server pull the created orders itself.
|
||||
export const createNagercoilDeliveries = async (variables = {}) => {
|
||||
const body = Array.isArray(variables.deliveries) && variables.deliveries.length
|
||||
? { deliveries: variables.deliveries }
|
||||
: {};
|
||||
const response = await axios.post(`${ROUTES_API_BASE}/optimization/nagercoil/riderassign`, body);
|
||||
return normaliseAssignResponse(response.data);
|
||||
};
|
||||
|
||||
// Push a notification to a rider after assignment.
|
||||
export const notifyRider = async (riderToken) => {
|
||||
if (!riderToken) {
|
||||
|
||||
440
src/pages/nearle/api/api.test.js
Normal file
440
src/pages/nearle/api/api.test.js
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Tests for the Nagercoil auto-assign path.
|
||||
*
|
||||
* Covers the two pieces added for the fixed-rider endpoint:
|
||||
* • normaliseAssignResponse — coerces whatever the solver returns into the
|
||||
* { zones, zone_summary, details } shape Preview.js and the embedded
|
||||
* Dispatch map consume.
|
||||
* • createNagercoilDeliveries — request shape, and that the response is
|
||||
* normalised on the way out.
|
||||
*
|
||||
* Plus the contract orders.js's empty-batch guard depends on, so a change to
|
||||
* the normaliser that breaks that guard fails here rather than in production.
|
||||
*/
|
||||
|
||||
jest.mock('axios', () => ({
|
||||
__esModule: true,
|
||||
default: { post: jest.fn(), get: jest.fn(), put: jest.fn() }
|
||||
}));
|
||||
|
||||
import axios from 'axios';
|
||||
import { normaliseAssignResponse, createNagercoilDeliveries } from './api';
|
||||
|
||||
const RIDER = { id: 852, name: 'Sivakumar Subramani' };
|
||||
|
||||
// Convenience: read back "which rider got which steps" from a normalised
|
||||
// response, which is what almost every assertion below cares about.
|
||||
const stepsByRider = (res) => {
|
||||
const out = {};
|
||||
(res.zones || []).forEach((z) => {
|
||||
(z.riders || []).forEach((r) => {
|
||||
const key = `${r.rider_name}#${r.rider_id}`;
|
||||
out[key] = (out[key] || []).concat(r.orders.map((o) => o.step));
|
||||
});
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const allOrders = (res) => (res.zones || []).flatMap((z) => (z.riders || []).flatMap((r) => r.orders));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// normaliseAssignResponse — already-zoned input
|
||||
// ==========================================================================
|
||||
describe('normaliseAssignResponse — zoned response', () => {
|
||||
const zoned = {
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'Nagercoil',
|
||||
riders: [
|
||||
{
|
||||
rider_id: RIDER.id,
|
||||
rider_name: RIDER.name,
|
||||
orders: [{ orderid: 1, step: 1 }, { orderid: 2, step: 2 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
zone_summary: []
|
||||
};
|
||||
|
||||
it('passes the zone tree through untouched', () => {
|
||||
const res = normaliseAssignResponse(zoned);
|
||||
expect(res.zones).toEqual(zoned.zones);
|
||||
});
|
||||
|
||||
it('backfills details from the tree when the response omits it', () => {
|
||||
const res = normaliseAssignResponse(zoned);
|
||||
expect(res.details).toHaveLength(2);
|
||||
expect(res.details[0]).toMatchObject({
|
||||
orderid: 1,
|
||||
zone_name: 'Nagercoil',
|
||||
rider_id: RIDER.id,
|
||||
userid: RIDER.id,
|
||||
rider_name: RIDER.name,
|
||||
rider: RIDER.name
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a details list the server already sent', () => {
|
||||
const withDetails = { ...zoned, details: [{ orderid: 99 }] };
|
||||
expect(normaliseAssignResponse(withDetails).details).toEqual([{ orderid: 99 }]);
|
||||
});
|
||||
|
||||
it('falls through to grouping when zones is present but empty', () => {
|
||||
const res = normaliseAssignResponse({
|
||||
zones: [],
|
||||
details: [{ orderid: 7, userid: RIDER.id, username: RIDER.name }]
|
||||
});
|
||||
expect(res.zones).toHaveLength(1);
|
||||
expect(res.details).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// normaliseAssignResponse — flat list input
|
||||
// ==========================================================================
|
||||
describe('normaliseAssignResponse — flat list', () => {
|
||||
it('groups orders into zones by delivery suburb', () => {
|
||||
const res = normaliseAssignResponse({
|
||||
details: [
|
||||
{ orderid: 10, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Vadasery', step: 1 },
|
||||
{ orderid: 11, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Vadasery', step: 2 },
|
||||
{ orderid: 12, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Ozhuginasery', step: 3 }
|
||||
]
|
||||
});
|
||||
expect(res.zones.map((z) => z.zone_name).sort()).toEqual(['Ozhuginasery', 'Vadasery']);
|
||||
expect(res.details).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('buckets suburbs case/whitespace-insensitively', () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 1, userid: RIDER.id, deliverysuburb: 'Vadasery' },
|
||||
{ orderid: 2, userid: RIDER.id, deliverysuburb: ' vadasery ' }
|
||||
]);
|
||||
expect(res.zones).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sequences steps globally per rider, not per zone', () => {
|
||||
// The regression this guards: Nagercoil sends one rider a single run that
|
||||
// spans suburbs. Numbering inside each zone would restart at 1 and
|
||||
// misrepresent the solver's ordering on the map pins and route lines.
|
||||
const res = normaliseAssignResponse({
|
||||
details: [
|
||||
{ orderid: 10, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Vadasery' },
|
||||
{ orderid: 11, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Vadasery' },
|
||||
{ orderid: 12, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Ozhuginasery' }
|
||||
]
|
||||
});
|
||||
const steps = allOrders(res)
|
||||
.map((o) => o.step)
|
||||
.sort((a, b) => a - b);
|
||||
expect(steps).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("honours the solver's own step ordering when rows arrive out of order", () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 'c', userid: RIDER.id, step: 3 },
|
||||
{ orderid: 'a', userid: RIDER.id, step: 1 },
|
||||
{ orderid: 'b', userid: RIDER.id, step: 2 }
|
||||
]);
|
||||
expect(allOrders(res).map((o) => o.orderid)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('accepts `sequence` and `stop_number` as step aliases', () => {
|
||||
const bySequence = normaliseAssignResponse([
|
||||
{ orderid: 'y', userid: RIDER.id, sequence: 2 },
|
||||
{ orderid: 'x', userid: RIDER.id, sequence: 1 }
|
||||
]);
|
||||
expect(allOrders(bySequence).map((o) => o.orderid)).toEqual(['x', 'y']);
|
||||
|
||||
const byStopNumber = normaliseAssignResponse([
|
||||
{ orderid: 'y', userid: RIDER.id, stop_number: 2 },
|
||||
{ orderid: 'x', userid: RIDER.id, stop_number: 1 }
|
||||
]);
|
||||
expect(allOrders(byStopNumber).map((o) => o.orderid)).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
it('sinks orders with no sequence below sequenced ones, keeping arrival order', () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 'no-step-1', userid: RIDER.id },
|
||||
{ orderid: 'seq-2', userid: RIDER.id, step: 2 },
|
||||
{ orderid: 'no-step-2', userid: RIDER.id },
|
||||
{ orderid: 'seq-1', userid: RIDER.id, step: 1 }
|
||||
]);
|
||||
expect(allOrders(res).map((o) => o.orderid)).toEqual(['seq-1', 'seq-2', 'no-step-1', 'no-step-2']);
|
||||
});
|
||||
|
||||
it('sequences each rider independently', () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 1, userid: 852, username: 'Sivakumar Subramani' },
|
||||
{ orderid: 2, userid: 852, username: 'Sivakumar Subramani' },
|
||||
{ orderid: 3, userid: 900, username: 'Other Rider' }
|
||||
]);
|
||||
expect(stepsByRider(res)).toEqual({
|
||||
'Sivakumar Subramani#852': [1, 2],
|
||||
'Other Rider#900': [1]
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps rider identity onto every order in both spellings', () => {
|
||||
// The Assign payload reads rider_id/userid; the map reads rider_name/rider.
|
||||
const res = normaliseAssignResponse([{ orderid: 1, userid: RIDER.id, username: RIDER.name }]);
|
||||
expect(allOrders(res)[0]).toMatchObject({
|
||||
rider_id: RIDER.id,
|
||||
userid: RIDER.id,
|
||||
rider_name: RIDER.name,
|
||||
rider: RIDER.name
|
||||
});
|
||||
});
|
||||
|
||||
it('reads rider name from any of rider_name / ridername / username', () => {
|
||||
const variants = [
|
||||
{ orderid: 1, userid: 852, rider_name: 'A' },
|
||||
{ orderid: 2, userid: 853, ridername: 'B' },
|
||||
{ orderid: 3, userid: 854, username: 'C' }
|
||||
];
|
||||
const names = allOrders(normaliseAssignResponse(variants)).map((o) => o.rider_name);
|
||||
expect(names.sort()).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('falls back to a synthetic rider name when none is given', () => {
|
||||
const res = normaliseAssignResponse([{ orderid: 1, userid: 852 }]);
|
||||
expect(allOrders(res)[0].rider_name).toBe('Rider 852');
|
||||
});
|
||||
|
||||
it('labels orders with no rider as Unassigned rather than dropping them', () => {
|
||||
const res = normaliseAssignResponse([{ orderid: 1, deliverysuburb: 'Vadasery' }]);
|
||||
expect(allOrders(res)).toHaveLength(1);
|
||||
expect(allOrders(res)[0].rider_name).toBe('Unassigned');
|
||||
expect(res.zone_summary[0].unassigned_orders_count).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults trip_number to 1 and preserves it when present', () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 1, userid: RIDER.id },
|
||||
{ orderid: 2, userid: RIDER.id, trip_number: 2 }
|
||||
]);
|
||||
const trips = allOrders(res).map((o) => o.trip_number);
|
||||
expect(trips).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('preserves solver fields the map and pricing depend on', () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 1, userid: RIDER.id, kms: '3.5', droplat: '8.17', droplon: '77.43', eta: '12:40', cumulativekms: 3.5 }
|
||||
]);
|
||||
expect(allOrders(res)[0]).toMatchObject({
|
||||
kms: '3.5',
|
||||
droplat: '8.17',
|
||||
droplon: '77.43',
|
||||
eta: '12:40',
|
||||
cumulativekms: 3.5
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults the zone name to Nagercoil when the order carries no suburb', () => {
|
||||
const res = normaliseAssignResponse([{ orderid: 1, userid: RIDER.id }]);
|
||||
expect(res.zones[0].zone_name).toBe('Nagercoil');
|
||||
});
|
||||
|
||||
it('prefers an explicit zone_name over the delivery suburb', () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 1, userid: RIDER.id, zone_name: 'Zone A', deliverysuburb: 'Vadasery' }
|
||||
]);
|
||||
expect(res.zones[0].zone_name).toBe('Zone A');
|
||||
});
|
||||
|
||||
it('computes zone_summary totals', () => {
|
||||
const res = normaliseAssignResponse([
|
||||
{ orderid: 1, userid: RIDER.id, deliverysuburb: 'Vadasery', kms: '2', profit: '10' },
|
||||
{ orderid: 2, userid: RIDER.id, deliverysuburb: 'Vadasery', actualkms: '3', profit: '5' }
|
||||
]);
|
||||
expect(res.zone_summary[0]).toMatchObject({
|
||||
zone_name: 'Vadasery',
|
||||
total_orders: 2,
|
||||
assigned_orders: 2,
|
||||
unassigned_orders_count: 0,
|
||||
active_riders_count: 1,
|
||||
total_delivery_kms: 5,
|
||||
total_profit: 15
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// normaliseAssignResponse — envelope tolerance
|
||||
// ==========================================================================
|
||||
describe('normaliseAssignResponse — envelope variants', () => {
|
||||
const one = [{ orderid: 1, userid: RIDER.id, username: RIDER.name }];
|
||||
|
||||
it.each([['details'], ['deliveries'], ['orders'], ['assignments'], ['data']])(
|
||||
'unwraps orders held under `%s`',
|
||||
(key) => {
|
||||
const res = normaliseAssignResponse({ [key]: one });
|
||||
expect(res.details).toHaveLength(1);
|
||||
expect(res.zones).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts a bare array', () => {
|
||||
const res = normaliseAssignResponse(one);
|
||||
expect(res.details).toHaveLength(1);
|
||||
expect(stepsByRider(res)).toEqual({ 'Sivakumar Subramani#852': [1] });
|
||||
});
|
||||
|
||||
it('carries top-level metadata through onto the result', () => {
|
||||
const res = normaliseAssignResponse({ request_id: 'abc123', details: one });
|
||||
expect(res.request_id).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// normaliseAssignResponse — empty and malformed input
|
||||
// ==========================================================================
|
||||
describe('normaliseAssignResponse — empty and malformed', () => {
|
||||
it('returns empty collections for the live empty-batch response', () => {
|
||||
const res = normaliseAssignResponse({
|
||||
userid: RIDER.id,
|
||||
username: RIDER.name,
|
||||
message: 'No created orders to assign'
|
||||
});
|
||||
expect(res.zones).toEqual([]);
|
||||
expect(res.zone_summary).toEqual([]);
|
||||
expect(res.details).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves the server message on an empty batch so the UI can show it', () => {
|
||||
const res = normaliseAssignResponse({ message: 'No created orders to assign' });
|
||||
expect(res.message).toBe('No created orders to assign');
|
||||
});
|
||||
|
||||
it.each([[null], [undefined], [''], [0], [false], [[]], [{}], ['nonsense'], [42]])(
|
||||
'returns an empty, well-formed result for %p instead of throwing',
|
||||
(input) => {
|
||||
const res = normaliseAssignResponse(input);
|
||||
expect(res.zones).toEqual([]);
|
||||
expect(res.zone_summary).toEqual([]);
|
||||
expect(res.details).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not mutate the response it was handed', () => {
|
||||
const input = { details: [{ orderid: 1, userid: RIDER.id }] };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
normaliseAssignResponse(input);
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// createNagercoilDeliveries — request/response wiring
|
||||
// ==========================================================================
|
||||
describe('createNagercoilDeliveries', () => {
|
||||
const URL = 'https://routes.workolik.com/api/v1/optimization/nagercoil/riderassign';
|
||||
|
||||
it('posts to the nagercoil riderassign endpoint', async () => {
|
||||
axios.post.mockResolvedValue({ data: { details: [] } });
|
||||
await createNagercoilDeliveries({ deliveries: [{ orderid: 1 }] });
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
expect(axios.post.mock.calls[0][0]).toBe(URL);
|
||||
});
|
||||
|
||||
it('sends the deliveries list when the operator has rows loaded', async () => {
|
||||
axios.post.mockResolvedValue({ data: { details: [] } });
|
||||
const deliveries = [{ orderid: 1 }, { orderid: 2 }];
|
||||
await createNagercoilDeliveries({ deliveries });
|
||||
expect(axios.post.mock.calls[0][1]).toEqual({ deliveries });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no argument at all', undefined],
|
||||
['an empty object', {}],
|
||||
['an empty deliveries array', { deliveries: [] }],
|
||||
['a non-array deliveries value', { deliveries: 'nope' }]
|
||||
])('sends an empty body for %s, so the server pulls created orders itself', async (_label, input) => {
|
||||
axios.post.mockResolvedValue({ data: { details: [] } });
|
||||
await createNagercoilDeliveries(input);
|
||||
expect(axios.post.mock.calls[0][1]).toEqual({});
|
||||
});
|
||||
|
||||
it('normalises a flat solver response before returning it', async () => {
|
||||
axios.post.mockResolvedValue({
|
||||
data: {
|
||||
details: [
|
||||
{ orderid: 10, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Vadasery' },
|
||||
{ orderid: 11, userid: RIDER.id, username: RIDER.name, deliverysuburb: 'Ozhuginasery' }
|
||||
]
|
||||
}
|
||||
});
|
||||
const res = await createNagercoilDeliveries({ deliveries: [{ orderid: 10 }] });
|
||||
expect(res.zones).toHaveLength(2);
|
||||
expect(res.details).toHaveLength(2);
|
||||
expect(stepsByRider(res)).toEqual({ 'Sivakumar Subramani#852': [1, 2] });
|
||||
});
|
||||
|
||||
it('returns an empty-but-valid result for the empty-batch response', async () => {
|
||||
axios.post.mockResolvedValue({
|
||||
data: { userid: RIDER.id, username: RIDER.name, message: 'No created orders to assign' }
|
||||
});
|
||||
const res = await createNagercoilDeliveries({ deliveries: [] });
|
||||
expect(res.details).toEqual([]);
|
||||
expect(res.message).toBe('No created orders to assign');
|
||||
});
|
||||
|
||||
it('propagates a transport error to the mutation onError handler', async () => {
|
||||
axios.post.mockRejectedValue(new Error('Network Error'));
|
||||
await expect(createNagercoilDeliveries({ deliveries: [] })).rejects.toThrow('Network Error');
|
||||
});
|
||||
|
||||
it('honours REACT_APP_ROUTES_URL when set', async () => {
|
||||
const prev = process.env.REACT_APP_ROUTES_URL;
|
||||
process.env.REACT_APP_ROUTES_URL = 'https://staging.example.com/api/v1';
|
||||
try {
|
||||
let api;
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line global-require
|
||||
api = require('./api');
|
||||
});
|
||||
axios.post.mockResolvedValue({ data: { details: [] } });
|
||||
await api.createNagercoilDeliveries({ deliveries: [] });
|
||||
expect(axios.post.mock.calls[0][0]).toBe(
|
||||
'https://staging.example.com/api/v1/optimization/nagercoil/riderassign'
|
||||
);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.REACT_APP_ROUTES_URL;
|
||||
else process.env.REACT_APP_ROUTES_URL = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// The contract orders.js's empty-batch guard relies on
|
||||
// ==========================================================================
|
||||
describe('orders.js empty-batch guard contract', () => {
|
||||
// orders.js branches on `!data?.details?.length` to decide between showing
|
||||
// an info snackbar and navigating to /nearle/dispatch/preview. These pin
|
||||
// that contract so a normaliser change can't silently push an empty preview.
|
||||
const guardFires = (data) => !data?.details?.length;
|
||||
|
||||
it('fires for the empty-batch response', () => {
|
||||
expect(guardFires(normaliseAssignResponse({ message: 'No created orders to assign' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('fires for a malformed response', () => {
|
||||
expect(guardFires(normaliseAssignResponse(null))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not fire for a populated flat response', () => {
|
||||
expect(guardFires(normaliseAssignResponse([{ orderid: 1, userid: RIDER.id }]))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fire for a populated zoned response', () => {
|
||||
const zoned = {
|
||||
zones: [{ zone_name: 'Nagercoil', riders: [{ rider_id: RIDER.id, rider_name: RIDER.name, orders: [{ orderid: 1 }] }] }]
|
||||
};
|
||||
expect(guardFires(normaliseAssignResponse(zoned))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,7 @@ import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
fetchPercentageData,
|
||||
createAutomationDeliveries,
|
||||
createNagercoilDeliveries,
|
||||
cancelMultipleOrder,
|
||||
getallriders,
|
||||
fetchorderscount
|
||||
@@ -353,12 +353,26 @@ const Orders = () => {
|
||||
// ==============================|| Mutations ||============================== //
|
||||
|
||||
const createDeliveryMutation = useMutation({
|
||||
mutationFn: createAutomationDeliveries,
|
||||
mutationFn: createNagercoilDeliveries,
|
||||
onSuccess: (data, variables) => {
|
||||
enqueueSnackbar('Orders Optimised Successfully', { variant: 'success', autoHideDuration: 2000, anchorOrigin: { vertical: 'top', horizontal: 'right' } });
|
||||
orderscountRefetch();
|
||||
refetchOrders();
|
||||
setCreateloader(false);
|
||||
|
||||
// The endpoint answers 200 with an empty assignment when there is
|
||||
// nothing to route (no created orders in the window). That is a normal
|
||||
// outcome, not an error — surface it here rather than pushing an empty
|
||||
// preview the operator would have to back out of.
|
||||
if (!data?.details?.length) {
|
||||
enqueueSnackbar(data?.message || 'No created orders to assign', {
|
||||
variant: 'info',
|
||||
autoHideDuration: 3000,
|
||||
anchorOrigin: { vertical: 'top', horizontal: 'right' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
enqueueSnackbar('Orders Optimised Successfully', { variant: 'success', autoHideDuration: 2000, anchorOrigin: { vertical: 'top', horizontal: 'right' } });
|
||||
navigate('/nearle/dispatch/preview', {
|
||||
state: {
|
||||
dispatchPreviewData: data,
|
||||
@@ -420,12 +434,10 @@ const Orders = () => {
|
||||
deliverylocation: val.deliverysuburb
|
||||
}));
|
||||
|
||||
createDeliveryMutation.mutate({
|
||||
deliveries: deliveryData,
|
||||
selectedMode: { value: 1 },
|
||||
hypertuning_params: 'balanced',
|
||||
absent_riders: []
|
||||
});
|
||||
// Scope the run to the rows the operator is looking at. The endpoint also
|
||||
// accepts an empty body and pulls the created orders itself — passing the
|
||||
// list explicitly keeps the assignment matched to the button's count.
|
||||
createDeliveryMutation.mutate({ deliveries: deliveryData });
|
||||
};
|
||||
|
||||
// KPI tile definitions.
|
||||
|
||||
Reference in New Issue
Block a user