) : (
@@ -887,6 +1030,14 @@ export default function MultipleOrders() {
| # |
Customer |
+ {/* Only in customer-pickup mode, and deliberately BEFORE the
+ delivery address: a geocoded sender line is the one thing
+ on this screen the operator has not seen with their own
+ eyes. A coarse "Edayarpalayam, Coimbatore" resolves to a
+ landmark in that locality, not the kitchen, and a rider
+ sent there arrives at the wrong door. Better caught here
+ than after thirteen orders are live. */}
+ {isCustomerPickup && Collect from | }
Address |
Qty |
Cash (₹) |
@@ -905,6 +1056,30 @@ export default function MultipleOrders() {
{row.firstname || '—'}
{row.contactno || '—'}
+ {isCustomerPickup && (
+
+ {row.__pickup ? (
+ <>
+
+ {row.__pickup.locationname || 'Customer Pickup'}
+
+
+ {row.__pickup.address}
+
+ >
+ ) : (
+
+ Fell back to hub
+
+ )}
+ |
+ )}
{needsAddress ? (
statusesInGroup(tab), [tab]);
+ /* Which end of each booking is a facility. This page reads RAW bookings
+ rather than the deliveries feed, so it has to classify for itself — but
+ against the same shared anchor list, so a row cannot read "customer
+ pickup" here and "hub run" on Dispatch. */
+ const { data: flowAnchors = [] } = useFlowAnchors((bookings || []).map((b) => b?.tenantid));
+
const rows = useMemo(
() =>
(zoneBookings || [])
@@ -315,17 +327,37 @@ export default function Orders() {
customer?.firstname ||
customer?.name ||
(row.appcustomerid ? `Customer #${row.appcustomerid}` : 'Drop');
+ /* "Pickup" alone reads as "collected from our hub", which is what it
+ always meant. Once it can also mean a doorstep, the row has to say
+ which — otherwise two very different journeys look identical in the
+ list. Silent on an ordinary hub run, so the label only appears
+ where it carries information. */
+ const flow = classifyBooking(row, flowAnchors);
+ const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer';
return (
- Pickup
+
+ {isCustomerPickup ? 'Customer pickup' : 'Pickup'}
+
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
-
- {dropName}
- {row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
- {row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
-
+ {/* Collapsed, a multi-drop row reads as ONE pickup request that
+ created several shipments — never as one of them. The flat
+ `deliveryaddress` it would otherwise show is destination 0
+ mirrored by CreateCxBooking, so on a three-drop pickup it is
+ Chennai standing in for all three. Open the row for the legs. */}
+ {isMultiDrop(row) ? (
+
+ {dropSummary(row)}
+
+ ) : (
+
+ {dropName}
+ {row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
+ {row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
+
+ )}
);
},
@@ -361,6 +393,21 @@ export default function Orders() {
return ;
}
if (rawStatus === 'converted_to_consignment') {
+ /* THE ONE THAT WOULD SILENTLY LIE.
+ `pickupbookings.consignmentid` names the FIRST leg only — set
+ that way at pickup-complete so single-drop screens keep working
+ — and `consignmentstatus` is joined from it. On a three-drop
+ pickup that is Chennai's status wearing the whole booking's
+ name, and rendering it here would confidently report "Delivered"
+ while two parcels are still in transit.
+
+ A multi-drop row therefore shows the PICKUP's own state, which
+ is genuinely finished once the rider has collected. Per-leg
+ status lives in the drawer, read from each destination's own
+ stage. */
+ if (isMultiDrop(row)) {
+ return ;
+ }
const cStatus = row.consignmentstatus ?? row.consignment_status;
return ;
}
@@ -476,6 +523,18 @@ export default function Orders() {
{!isAllZones && (
· Zone: {selectedZone.hubname}
)}
+ {/* Search runs over the flat booking columns, and `deliveryaddress`
+ is destination 0 mirrored by CreateCxBooking. Searching
+ "Ernakulam" on a three-drop pickup whose first drop is Chennai
+ therefore finds nothing. The list has no destination array to
+ index — it carries only the counts, deliberately — so the honest
+ fix is to say which drop is searched rather than to fail
+ silently. */}
+ {debouncedSearch && (
+
+ {' '}· search covers the order number, pickup, notes and the first drop only
+
+ )}
{/* A capped scan means an order really can be missing from this tab.
Say so — a silently short list is what sent operators hunting for
an order they had just created. */}
@@ -493,6 +552,12 @@ export default function Orders() {
columns={columns}
rows={rows}
getRowId={(row) => row.bookingid}
+ /* Opens the drops for ANY booking, not only multi-drop ones: the
+ per-destination recipient, instructions and COD exist nowhere else
+ in the console, and a row that is clickable only sometimes is worse
+ than one that always is. The checkbox cell stops propagation, so
+ this does not fight selection. */
+ onRowClick={(row) => setDropsRow(row)}
loading={isLoading}
refreshing={isFetching && !isLoading}
selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined}
@@ -630,6 +695,15 @@ export default function Orders() {
})
}
/>
+
+ {
+ if (!next) setDropsRow(null);
+ }}
+ bookingId={dropsRow?.bookingid}
+ bookingNo={dropsRow?.bookingno || (dropsRow ? `#${dropsRow.bookingid}` : undefined)}
+ />
);
}
diff --git a/tests/fixtures/doormile-logistics-test.xlsx b/tests/fixtures/doormile-logistics-test.xlsx
new file mode 100644
index 0000000..39e0093
Binary files /dev/null and b/tests/fixtures/doormile-logistics-test.xlsx differ
diff --git a/tests/fixtures/jayanthi-kitchen-route.xls b/tests/fixtures/jayanthi-kitchen-route.xls
new file mode 100644
index 0000000..a00ab7f
Binary files /dev/null and b/tests/fixtures/jayanthi-kitchen-route.xls differ
diff --git a/tests/integration/bulkUpload.test.js b/tests/integration/bulkUpload.test.js
new file mode 100644
index 0000000..13e11bd
--- /dev/null
+++ b/tests/integration/bulkUpload.test.js
@@ -0,0 +1,459 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import * as XLSX from 'xlsx';
+
+import {
+ SHEET_HEADER_MAP,
+ cleanReceiverName,
+ normalizeHeader,
+ requiredSheetColumns
+} from '@/lib/bulkOrderColumns';
+import { buildBulkBookingPayloads, buildParcels } from '@/lib/bulkOrderPayload';
+import { FLOW, buildAnchors, classifyBooking } from '@/lib/orderFlow';
+import { calculateTotalCharge } from '@/lib/distance';
+
+/**
+ * Bulk upload, end to end, against a REAL tenant file.
+ *
+ * `tests/fixtures/jayanthi-kitchen-route.xls` is an actual export a tenant
+ * uploaded: 13 lunch deliveries around Coimbatore, leaving from THREE different
+ * home kitchens — Jayanthi (6 drops), Latha (3), Manjusha (4).
+ *
+ * That file is the whole reason this suite exists. The page's bulk path was
+ * built on the assumption that one upload has one origin: it parsed the sheet's
+ * Sender columns and then overwrote every row's pickup with the single business
+ * location the operator had selected. Uploading this file therefore booked all
+ * 13 orders against one kitchen and silently erased the other two — the rider
+ * would be sent to Edayarpalayam for parcels sitting in Kavundampalayam.
+ *
+ * These tests drive the REAL modules the page imports — the real header map,
+ * the real payload builder, the real flow classifier. Nothing is reimplemented
+ * here, so a passing test is a statement about what the page does rather than
+ * about a copy of it that happens to agree today.
+ *
+ * The one thing deliberately NOT exercised is network geocoding. The fixture
+ * carries Receiver Latitude/Longitude for every row, so drops need no lookup;
+ * sender coordinates are injected from a stub table below, standing in for what
+ * the geocoder returns. That keeps the suite hermetic and deterministic while
+ * still covering every transformation that decides what goes on the wire.
+ */
+
+const FIXTURE = path.join(__dirname, '..', 'fixtures', 'jayanthi-kitchen-route.xls');
+
+// The tenant's selected business location — one origin, as the page has always
+// assumed. Coordinates are Jayanthi's kitchen.
+const BUSINESS_LOCATION = {
+ tenantlocationid: 31,
+ tenantid: 4,
+ hubid: 7,
+ locationname: 'Jayanthi Kitchen',
+ address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025',
+ city: 'Coimbatore',
+ pincode: '641025',
+ latitude: 11.0168,
+ longitude: 76.9558
+};
+
+const DOORMILE_HUB = {
+ hubid: 7,
+ hubname: 'Coimbatore Gandhipuram Hub',
+ latitude: 11.0168,
+ longitude: 76.9558
+};
+
+/* Stands in for the geocoder. Keyed by the sheet's exact Sender Address text —
+ the three kitchens the file actually contains. */
+const SENDER_COORDS = {
+ 'Edayarpalayam, Coimbatore, Tamil Nadu 641025': { lat: 11.0168, lng: 76.9558 },
+ 'Kavundampalayam, 641030': { lat: 11.0447, lng: 76.9382 },
+ 'Ramakrishnamill, Ganapathy, Coimbatore - 641006': { lat: 11.0296, lng: 76.9891 }
+};
+
+const RATE = { basePrice: 40, pricePerKm: 8, minKm: 5 };
+const DELIVERY_TIME = '2026-09-01 09:00:00';
+
+// ---- the page's own parse, reproduced from MultipleOrders.processData -------
+// The mapping table and normalizer are imported, not copied; only the loop that
+// walks them is restated, because it lives inside a component.
+const parseFixture = () => {
+ const wb = XLSX.read(fs.readFileSync(FIXTURE), { type: 'buffer' });
+ const sheet = wb.Sheets[wb.SheetNames[0]];
+ const raw = XLSX.utils.sheet_to_json(sheet, { defval: '' });
+
+ const normalizedMap = {};
+ for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key];
+
+ const rows = raw.map((row, idx) => {
+ const out = { _rowkey: `csv-${idx}`, quantity: 1, collectionamt: 0 };
+ for (const key in row) {
+ const clean = normalizeHeader(key);
+ const mapped = normalizedMap[clean] || clean;
+ out[mapped] = mapped === 'firstname' ? cleanReceiverName(row[key]) : row[key];
+ }
+ return out;
+ });
+
+ return { raw, rows, headers: Object.keys(raw[0]).map(normalizeHeader) };
+};
+
+/** Straight-line km, standing in for the OSRM call the page makes. Only used to
+ * give each row a plausible price so the payload has a real number in it. */
+const km = (a, b) => {
+ const toRad = (d) => (d * Math.PI) / 180;
+ const dLat = toRad(b.lat - a.lat);
+ const dLon = toRad(b.lng - a.lng);
+ const h =
+ Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
+ return Math.round(6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
+};
+
+/** Rows as the page holds them after processing: coordinates resolved, price
+ * computed, and `__pickup` attached when the run collects from customers. */
+const processRows = (rows, { customerPickup }) =>
+ rows.map((row) => {
+ const senderAddress = String(row.locationaddress || '').trim();
+ const coords = SENDER_COORDS[senderAddress];
+ const pickup =
+ customerPickup && coords
+ ? {
+ locationname: row.locationname || 'Customer Pickup',
+ contactno: String(row.locationcontact || '').replace(/\D/g, '').slice(0, 10),
+ address: senderAddress,
+ city: '',
+ pincode: '',
+ latitude: coords.lat,
+ longitude: coords.lng
+ }
+ : null;
+
+ const origin = pickup || BUSINESS_LOCATION;
+ const distance = km(
+ { lat: Number(origin.latitude), lng: Number(origin.longitude) },
+ { lat: Number(row.latitude), lng: Number(row.longitude) }
+ );
+
+ return {
+ ...row,
+ __pickup: pickup,
+ __pickupFellBack: customerPickup && !pickup,
+ distance,
+ totalcharge: calculateTotalCharge(distance, RATE.basePrice, RATE.pricePerKm, RATE.minKm)
+ };
+ });
+
+const anchors = buildAnchors({ hubs: [DOORMILE_HUB], tenantLocations: [BUSINESS_LOCATION] });
+
+const payloadsFor = (mode) =>
+ buildBulkBookingPayloads({
+ rows: processRows(parseFixture().rows, { customerPickup: mode === 'customer' }),
+ sharedPickup: BUSINESS_LOCATION,
+ tenantId: 4,
+ tenantLocationId: 31,
+ deliverytime: DELIVERY_TIME,
+ notes: 'Lunch run',
+ anchors
+ });
+
+describe('bulk upload — Jayanthi_Kitchen_Route.xls', () => {
+ describe('the file itself', () => {
+ it('should be a single sheet of 13 delivery rows', () => {
+ const { raw } = parseFixture();
+ expect(raw).toHaveLength(13);
+ });
+
+ it('should carry three distinct sender kitchens, not one', () => {
+ // The premise of the whole feature. If this ever collapses to one, the
+ // fixture has been replaced and these tests no longer prove anything.
+ const { rows } = parseFixture();
+ const senders = new Set(rows.map((r) => r.locationaddress));
+ expect(senders.size).toBe(3);
+ expect([...senders].sort()).toEqual([
+ 'Edayarpalayam, Coimbatore, Tamil Nadu 641025',
+ 'Kavundampalayam, 641030',
+ 'Ramakrishnamill, Ganapathy, Coimbatore - 641006'
+ ]);
+ });
+
+ it('should split as 6 / 3 / 4 across the three kitchens', () => {
+ const { rows } = parseFixture();
+ const counts = rows.reduce((acc, r) => {
+ acc[r.locationname] = (acc[r.locationname] || 0) + 1;
+ return acc;
+ }, {});
+ expect(counts).toEqual({ Jayanthi: 6, Latha: 3, Manjusha: 4 });
+ });
+ });
+
+ describe('header mapping', () => {
+ it('should map every sheet column the app knows about', () => {
+ const { raw } = parseFixture();
+ const normalizedMap = {};
+ for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key];
+
+ const unmapped = Object.keys(raw[0]).filter((h) => !normalizedMap[normalizeHeader(h)]);
+ // "Address id" is the tenant's own upstream record id. It is carried
+ // through unmapped and unused, which is correct — it means nothing here.
+ expect(unmapped).toEqual(['Address id']);
+ });
+
+ it('should tolerate the whitespace and casing the real header row carries', () => {
+ // The live header is "Pickup Date (YYYY-MMM-DD )" — note the space before
+ // the bracket. normalizeHeader strips ALL whitespace, which is the only
+ // reason this column resolves; a mapping keyed on the literal string
+ // would miss it.
+ expect(normalizeHeader('Pickup Date (YYYY-MMM-DD )')).toBe('pickupdate(yyyy-mmm-dd)');
+ expect(SHEET_HEADER_MAP['pickupdate(yyyy-mmm-dd)']).toBe('date');
+ });
+
+ it('should report no missing required columns', () => {
+ const { headers } = parseFixture();
+ const missing = requiredSheetColumns().filter((c) => !headers.includes(normalizeHeader(c)));
+ expect(missing).toEqual([]);
+ });
+
+ it('should give every row a delivery address and usable coordinates', () => {
+ const { rows } = parseFixture();
+ rows.forEach((row) => {
+ expect(String(row.address || '').trim()).not.toBe('');
+ expect(Number(row.latitude)).toBeGreaterThan(10);
+ expect(Number(row.longitude)).toBeGreaterThan(76);
+ });
+ });
+
+ it('should leave delivery city and pincode empty, because the sheet has no such columns', () => {
+ // Documented rather than asserted-away. bulkOrderColumns maps
+ // `receivercity`/`receiverpincode` speculatively — this real file proves
+ // the tenant does not send them, so deliverycity/deliverypincode go to the
+ // backend blank on every row of every upload from this tenant.
+ const { rows } = parseFixture();
+ expect(rows.every((r) => !r.city)).toBe(true);
+ expect(rows.every((r) => !r.postcode)).toBe(true);
+ });
+ });
+
+ describe('parcels — quantity and description', () => {
+ it('should expand quantity into one parcel entry per item', () => {
+ // Not cosmetic: the app reads quantity back as `parcels.length`
+ // everywhere downstream, so a 5-item order collapsed to one entry tells
+ // the rider to collect one bag.
+ expect(buildParcels({ quantity: 5, description: 'Low GI Carb Meal' })).toHaveLength(5);
+ expect(buildParcels({ quantity: 1 })).toHaveLength(1);
+ });
+
+ it('should never emit zero parcels, whatever the sheet says', () => {
+ [0, -3, '', null, undefined, 'abc', 1.4].forEach((quantity) => {
+ expect(buildParcels({ quantity }).length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("should carry the sheet's own item description, not a placeholder", () => {
+ const [parcel] = buildParcels({ quantity: 1, description: 'Economy Lunch Lunch - Jayanthi-Delivery' });
+ expect(parcel.itemdescription).toBe('Economy Lunch Lunch - Jayanthi-Delivery');
+ });
+
+ it('should fall back to a generic description only when the sheet gives none', () => {
+ expect(buildParcels({ quantity: 1, description: ' ' })[0].itemdescription).toBe('Order');
+ });
+
+ it('should produce 19 parcels across the 13 rows of the real file', () => {
+ // 2+1+1+2+1+1+1+5+1+1+1+1+1 = 19. Before quantity was honoured this was 13.
+ const total = payloadsFor('hub').reduce((sum, b) => sum + b.parcels.length, 0);
+ expect(total).toBe(19);
+ });
+ });
+
+ describe('hub mode — every row collected from the one selected location', () => {
+ const payloads = payloadsFor('hub');
+
+ it('should produce one booking per sheet row', () => {
+ expect(payloads).toHaveLength(13);
+ });
+
+ it('should write the business location as the pickup on every row', () => {
+ payloads.forEach((b) => {
+ expect(b.pickupaddress).toBe(BUSINESS_LOCATION.address);
+ expect(b.pickuplatitude).toBe(BUSINESS_LOCATION.latitude);
+ expect(b.pickuplongitude).toBe(BUSINESS_LOCATION.longitude);
+ });
+ });
+
+ it('should classify every row as an ordinary hub run', () => {
+ payloads.forEach((b) => expect(b.ordertype).toBe(FLOW.FORWARD));
+ });
+
+ it('should carry each receiver through to the booking', () => {
+ expect(payloads[0].customer_name).toBe('Lalitha');
+ expect(payloads[0].customer_phone).toBe('9843174920');
+ expect(payloads[0].deliveryaddress).toContain('Lalitha boutique 36 Railway mens colony');
+ expect(payloads[0].deliverylatitude).toBeCloseTo(11.0506991, 6);
+ });
+
+ it('should send phone numbers as strings even though the sheet types them as numbers', () => {
+ // XLSX parses a phone cell as a Number. A number on the wire where the
+ // backend expects a string is the kind of thing that 400s the whole batch.
+ payloads.forEach((b) => {
+ expect(typeof b.customer_phone).toBe('string');
+ expect(b.customer_phone).toMatch(/^\d{10}$/);
+ });
+ });
+
+ it('should price every row and never send a zero or NaN charge', () => {
+ payloads.forEach((b) => {
+ expect(Number.isFinite(b.finalprice)).toBe(true);
+ expect(b.finalprice).toBeGreaterThanOrEqual(RATE.basePrice);
+ });
+ });
+
+ it('should stamp the servicing hub so the orders land in a zone', () => {
+ payloads.forEach((b) => {
+ expect(b.sourcehubid).toBe(7);
+ expect(b.hubid).toBe(7);
+ expect(b.tenantlocationid).toBe(31);
+ });
+ });
+ });
+
+ describe('customer-pickup mode — the case this file actually is', () => {
+ const payloads = payloadsFor('customer');
+
+ it('should give each row the pickup its OWN sender column names', () => {
+ // The defect this replaces: all 13 previously carried Jayanthi's address.
+ const byAddress = payloads.reduce((acc, b) => {
+ acc[b.pickupaddress] = (acc[b.pickupaddress] || 0) + 1;
+ return acc;
+ }, {});
+ expect(byAddress).toEqual({
+ 'Edayarpalayam, Coimbatore, Tamil Nadu 641025': 6,
+ 'Kavundampalayam, 641030': 3,
+ 'Ramakrishnamill, Ganapathy, Coimbatore - 641006': 4
+ });
+ });
+
+ it('should not write one origin across rows that came from another kitchen', () => {
+ // Row 8 is Kishore, collected at Latha's kitchen in Kavundampalayam.
+ const kishore = payloads.find((b) => b.customer_name === 'Kishore');
+ expect(kishore.pickupaddress).toBe('Kavundampalayam, 641030');
+ expect(kishore.pickuplatitude).toBeCloseTo(11.0447, 4);
+ expect(kishore.pickuplatitude).not.toBe(BUSINESS_LOCATION.latitude);
+ });
+
+ it('should give every row real pickup coordinates, never 0,0', () => {
+ payloads.forEach((b) => {
+ expect(b.pickuplatitude).not.toBe(0);
+ expect(b.pickuplongitude).not.toBe(0);
+ });
+ });
+
+ it('should classify a kitchen that IS a registered location as a hub run, not a doorstep', () => {
+ // Jayanthi's kitchen is the tenant's registered business location. Rows
+ // leaving it are hub pickups whatever mode the operator chose — the flow
+ // follows the facts, not the toggle.
+ const jayanthi = payloads.filter(
+ (b) => b.pickupaddress === 'Edayarpalayam, Coimbatore, Tamil Nadu 641025'
+ );
+ expect(jayanthi).toHaveLength(6);
+ jayanthi.forEach((b) => expect(b.ordertype).toBe(FLOW.FORWARD));
+ });
+
+ it("should classify the two UNREGISTERED kitchens as point-to-point collections", () => {
+ const others = payloads.filter(
+ (b) => b.pickupaddress !== 'Edayarpalayam, Coimbatore, Tamil Nadu 641025'
+ );
+ expect(others).toHaveLength(7);
+ others.forEach((b) => expect(b.ordertype).toBe(FLOW.P2P));
+ });
+
+ it('should price each row from ITS OWN kitchen, not from the shared hub', () => {
+ // The pricing consequence. Measuring every row from one hub bills a
+ // cross-town leg no rider ever makes.
+ const hub = payloadsFor('hub');
+ const kishoreHub = hub.find((b) => b.customer_name === 'Kishore');
+ const kishoreOwn = payloads.find((b) => b.customer_name === 'Kishore');
+ expect(kishoreOwn.finalprice).not.toBe(kishoreHub.finalprice);
+ });
+ });
+
+ describe('collection points on the dispatch board', () => {
+ /* Bookings as the board would read them back after creation. */
+ const asBookings = payloadsFor('customer').map((b, i) => ({
+ bookingid: 1000 + i,
+ pickuplatitude: b.pickuplatitude,
+ pickuplongitude: b.pickuplongitude,
+ deliverylatitude: b.deliverylatitude,
+ deliverylongitude: b.deliverylongitude,
+ pickupaddress: b.pickupaddress
+ }));
+
+ it('should draw exactly three collection pins, not thirteen and not one', () => {
+ // One pin per real kitchen. Grouping per booking would stack six markers
+ // on Jayanthi's door; grouping by tenant name — what the console did
+ // before — would draw a single pin for all three.
+ const keys = new Set(asBookings.map((b) => classifyBooking(b, anchors).pickupPointKey));
+ expect(keys.size).toBe(3);
+ });
+
+ it('should group the six Jayanthi rows onto one pin', () => {
+ const jayanthiKeys = new Set(
+ asBookings
+ .filter((b) => b.pickupaddress === 'Edayarpalayam, Coimbatore, Tamil Nadu 641025')
+ .map((b) => classifyBooking(b, anchors).pickupPointKey)
+ );
+ expect(jayanthiKeys.size).toBe(1);
+ });
+
+ it('should keep the three kitchens on separate pins', () => {
+ const byAddress = new Map();
+ asBookings.forEach((b) => {
+ byAddress.set(b.pickupaddress, classifyBooking(b, anchors).pickupPointKey);
+ });
+ expect(new Set(byAddress.values()).size).toBe(3);
+ });
+ });
+
+ describe('regression guards', () => {
+ it('should never lose a row between sheet and wire', () => {
+ const { raw } = parseFixture();
+ expect(payloadsFor('hub')).toHaveLength(raw.length);
+ expect(payloadsFor('customer')).toHaveLength(raw.length);
+ });
+
+ it('should keep sheet order, so the preview an operator approved is what is sent', () => {
+ const { rows } = parseFixture();
+ const names = payloadsFor('customer').map((b) => b.customer_name);
+ expect(names).toEqual(rows.map((r) => r.firstname));
+ });
+
+ it('should put the same delivery time on every booking in one run', () => {
+ payloadsFor('hub').forEach((b) => expect(b.deliverytime).toBe(DELIVERY_TIME));
+ });
+
+ it('should apply the run-level note to every booking', () => {
+ payloadsFor('hub').forEach((b) => expect(b.notes).toBe('Lunch run'));
+ });
+
+ it('should send a numeric tenantid, not the string the form holds', () => {
+ payloadsFor('hub').forEach((b) => expect(b.tenantid).toBe(4));
+ });
+
+ it('should fall back to the shared hub when a sender address will not geocode', () => {
+ // The row is still booked — dropping it silently would be worse — but it
+ // is booked at the hub, and the page tells the operator how many did this.
+ const rows = processRows(parseFixture().rows, { customerPickup: true }).map((r) =>
+ r.customer_name === 'Kishore' ? r : { ...r, __pickup: null, __pickupFellBack: true }
+ );
+ const payloads = buildBulkBookingPayloads({
+ rows,
+ sharedPickup: BUSINESS_LOCATION,
+ tenantId: 4,
+ tenantLocationId: 31,
+ deliverytime: DELIVERY_TIME,
+ anchors
+ });
+ expect(payloads).toHaveLength(13);
+ payloads.forEach((b) => {
+ expect(b.pickupaddress).toBe(BUSINESS_LOCATION.address);
+ expect(b.ordertype).toBe(FLOW.FORWARD);
+ });
+ });
+ });
+});
diff --git a/tests/integration/logisticsBulkUpload.test.js b/tests/integration/logisticsBulkUpload.test.js
new file mode 100644
index 0000000..324486f
--- /dev/null
+++ b/tests/integration/logisticsBulkUpload.test.js
@@ -0,0 +1,298 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import * as XLSX from 'xlsx';
+
+import { SHEET_HEADER_MAP, cleanReceiverName, normalizeHeader, requiredSheetColumns } from '@/lib/bulkOrderColumns';
+import { buildBulkBookingPayloads } from '@/lib/bulkOrderPayload';
+import { PICKUP_SOURCE_TYPE, buildAnchors, classifyBooking } from '@/lib/orderFlow';
+
+/**
+ * Bulk upload for the LOGISTICS path — the base-handover flow.
+ *
+ * `tests/fixtures/doormile-logistics-test.xlsx` exists because the tenant's own
+ * export (jayanthi-kitchen-route.xls, covered by bulkUpload.test.js) is 13 lunch
+ * drops entirely inside Coimbatore. Every row of it is hyperlocal, so uploading
+ * it exercises none of the intercity path: no parcel is ever routed to a base,
+ * and the rider is never told to hand one over.
+ *
+ * This fixture keeps that file's exact column titles — so it travels through
+ * the same header map and the same page code — and varies only the delivery
+ * end, which is what the routing decision is made from. 16 rows: 11 that must
+ * route through a base, 5 that must go straight to the customer.
+ *
+ * WHAT THIS SUITE DOES AND DOES NOT ASSERT.
+ *
+ * The routing decision is the BACKEND's, made in Go by isHyperlocalBooking.
+ * Re-implementing that rule here would only prove that two copies of it agree
+ * with each other today — the exact trap this repo's other suites call out. So
+ * the destinations are asserted against the real rule in
+ * doormile_backend/controllers/logisticsRouting_test.go, and what is asserted
+ * HERE is what the console is actually responsible for: that every row survives
+ * the sheet-to-wire transform with the fields the backend needs to make that
+ * decision, and that the pickup source type goes out correctly.
+ */
+
+const FIXTURE = path.join(__dirname, '..', 'fixtures', 'doormile-logistics-test.xlsx');
+
+// The tenant's selected pickup location — Jayanthi's kitchen, which is also a
+// registered site, at the same coordinates as the Coimbatore hub.
+const BUSINESS_LOCATION = {
+ tenantlocationid: 31,
+ tenantid: 4,
+ hubid: 7,
+ locationname: 'Jayanthi Kitchen',
+ address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025',
+ city: 'Coimbatore',
+ pincode: '641025',
+ latitude: 11.0168,
+ longitude: 76.9558
+};
+
+const DOORMILE_BASE = {
+ hubid: 7,
+ hubname: 'Coimbatore Gandhipuram Hub',
+ address: '14 Avinashi Road, Peelamedu, Coimbatore',
+ pincode: '641004',
+ latitude: 11.0272,
+ longitude: 76.9905
+};
+
+const DELIVERY_TIME = '2026-09-02 09:00:00';
+
+/** The page's own parse (MultipleOrders.processData), with the real map. */
+const parseFixture = () => {
+ const wb = XLSX.read(fs.readFileSync(FIXTURE), { type: 'buffer' });
+ const sheet = wb.Sheets[wb.SheetNames[0]];
+ const raw = XLSX.utils.sheet_to_json(sheet, { defval: '' });
+
+ const normalizedMap = {};
+ for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key];
+
+ const rows = raw.map((row, idx) => {
+ const out = { _rowkey: `csv-${idx}`, quantity: 1, collectionamt: 0 };
+ for (const key in row) {
+ const clean = normalizeHeader(key);
+ const mapped = normalizedMap[clean] || clean;
+ out[mapped] = mapped === 'firstname' ? cleanReceiverName(row[key]) : row[key];
+ }
+ return out;
+ });
+
+ return { raw, rows, headers: Object.keys(raw[0]).map(normalizeHeader) };
+};
+
+const processRows = (rows) =>
+ rows.map((row) => ({ ...row, __pickup: null, distance: 10, totalcharge: 120 }));
+
+const anchors = buildAnchors({ hubs: [DOORMILE_BASE], tenantLocations: [BUSINESS_LOCATION] });
+
+const payloads = buildBulkBookingPayloads({
+ rows: processRows(parseFixture().rows),
+ sharedPickup: BUSINESS_LOCATION,
+ tenantId: 4,
+ tenantLocationId: 31,
+ deliverytime: DELIVERY_TIME,
+ anchors
+});
+
+/** Same rule the backend applies, stated once here ONLY to partition the
+ * fixture for counting — never to assert an individual row's routing, which is
+ * the backend test's job. */
+const differentPostalArea = (p) =>
+ !(String(p.pickuppincode).length >= 3 &&
+ String(p.deliverypincode).length >= 3 &&
+ String(p.pickuppincode).slice(0, 3) === String(p.deliverypincode).slice(0, 3));
+
+describe('logistics bulk upload — doormile-logistics-test.xlsx', () => {
+ describe('the fixture itself', () => {
+ it('should be a single sheet of 16 rows', () => {
+ const { raw } = parseFixture();
+ expect(raw).toHaveLength(16);
+ });
+
+ it('should use the tenant sheet\'s own column titles, so nothing new has to be learned', () => {
+ const { headers } = parseFixture();
+ ['sendername*', 'senderphone*', 'senderaddress*', 'receivername*', 'receiveralternatephone*', 'itemdescription*']
+ .forEach((h) => expect(headers).toContain(h));
+ });
+
+ it('should report no missing required columns', () => {
+ const { headers } = parseFixture();
+ const missing = requiredSheetColumns().filter((c) => !headers.includes(normalizeHeader(c)));
+ expect(missing).toEqual([]);
+ });
+
+ it('should carry a pincode column, which the tenant file does not', () => {
+ // Without one, every row lands on the backend's distance fallback and the
+ // prefix rule — the thing that actually decides intercity — never runs.
+ const { headers } = parseFixture();
+ expect(headers).toContain('receiverpincode');
+ expect(SHEET_HEADER_MAP.receiverpincode).toBe('postcode');
+ });
+
+ it('should leave from ONE origin, so the delivery end is the only variable', () => {
+ const { rows } = parseFixture();
+ const senders = new Set(rows.map((r) => String(r.locationaddress).trim()));
+ expect(senders.size).toBe(1);
+ });
+
+ it('should actually split both ways, or it is testing nothing', () => {
+ // A fixture that turned out to be all-hyperlocal would pass every other
+ // assertion here and still exercise none of the handover path — which is
+ // precisely what is wrong with the tenant's own export.
+ const viaBase = payloads.filter(differentPostalArea);
+ expect(viaBase.length).toBeGreaterThanOrEqual(10);
+ expect(payloads.length - viaBase.length).toBeGreaterThanOrEqual(4);
+ });
+ });
+
+ describe('what the backend needs in order to route at all', () => {
+ it('should produce one booking per sheet row, losing none', () => {
+ expect(payloads).toHaveLength(16);
+ });
+
+ it('should give every row a pickup pincode, or nothing can be compared', () => {
+ payloads.forEach((p) => expect(String(p.pickuppincode)).toHaveLength(6));
+ });
+
+ it('should give every row real pickup coordinates, never 0,0', () => {
+ payloads.forEach((p) => {
+ expect(p.pickuplatitude).not.toBe(0);
+ expect(p.pickuplongitude).not.toBe(0);
+ });
+ });
+
+ it('should give every row delivery coordinates, so the distance fallback can run', () => {
+ // Three rows deliberately carry no usable pincode. For those, coordinates
+ // are the ONLY thing the backend can decide from.
+ payloads.forEach((p) => {
+ expect(Number(p.deliverylatitude)).not.toBe(0);
+ expect(Number(p.deliverylongitude)).not.toBe(0);
+ });
+ });
+
+ it('should carry the delivery pincode through as a string, not a number', () => {
+ // A pincode read as a number loses a leading zero and stops being 6 long,
+ // which silently changes which 3-digit area it compares as.
+ payloads.filter((p) => p.deliverypincode !== '').forEach((p) => {
+ expect(typeof p.deliverypincode).toBe('string');
+ });
+ });
+
+ it('should preserve the rows that intentionally have no usable pincode', () => {
+ // These are the fallback cases and must NOT be quietly filled in.
+ const unusable = payloads.filter((p) => String(p.deliverypincode).length < 3);
+ expect(unusable.length).toBe(3);
+ });
+ });
+
+ describe('pickup source — what kind of place these leave from', () => {
+ it('should name a source type on every single booking', () => {
+ // "customer" is a value, never an omission — an absent field reads as
+ // "nobody filled this in", which is a different fact from "front door".
+ payloads.forEach((p) => {
+ expect(p.pickup_source_type).toBeDefined();
+ expect(Object.values(PICKUP_SOURCE_TYPE)).toContain(p.pickup_source_type);
+ });
+ });
+
+ it('should call a collection at the client\'s registered site a merchant pickup', () => {
+ // Jayanthi's kitchen is a registered tenant location. Geometrically it is
+ // a facility, but to a rider it is a shop, not a base — and titling the
+ // stop "base" is what made every logistics pickup read as the rider's own
+ // office.
+ payloads.forEach((p) => expect(p.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT));
+ });
+
+ it('should not name a base on a pickup that is not from one', () => {
+ payloads.forEach((p) => expect(p.pickuphubid).toBeUndefined());
+ });
+
+ it('should stamp the servicing base so the orders land in a zone', () => {
+ payloads.forEach((p) => {
+ expect(p.sourcehubid).toBe(DOORMILE_BASE.hubid);
+ expect(p.hubid).toBe(DOORMILE_BASE.hubid);
+ });
+ });
+
+ it('should read back as a merchant pickup once the row returns from the server', () => {
+ // The write path and the read path must tell the same story.
+ const readBack = classifyBooking(
+ { ...payloads[0], bookingid: 1, pickup_source_type: payloads[0].pickup_source_type },
+ anchors
+ );
+ expect(readBack.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.MERCHANT);
+ expect(readBack.sourceTypeFromServer).toBe(true);
+ });
+ });
+
+ describe('a base-origin upload — the Base → Customer case', () => {
+ // Same sheet, but the operator has selected a BASE as the pickup point
+ // rather than a client site. This is the AC-2 journey: Home must show the
+ // base as the actual pickup source.
+ const fromBase = buildBulkBookingPayloads({
+ rows: processRows(parseFixture().rows),
+ sharedPickup: {
+ ...DOORMILE_BASE,
+ latitude: DOORMILE_BASE.latitude,
+ longitude: DOORMILE_BASE.longitude,
+ pincode: DOORMILE_BASE.pincode,
+ address: DOORMILE_BASE.address
+ },
+ tenantId: 4,
+ tenantLocationId: '',
+ deliverytime: DELIVERY_TIME,
+ anchors
+ });
+
+ it('should classify every row as a base pickup', () => {
+ fromBase.forEach((p) => expect(p.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB));
+ });
+
+ it('should carry the base id, which is what keys the rider handover', () => {
+ fromBase.forEach((p) => expect(p.pickuphubid).toBe(DOORMILE_BASE.hubid));
+ });
+
+ it('should put the base address on the booking, not a customer door', () => {
+ fromBase.forEach((p) => expect(p.pickupaddress).toBe(DOORMILE_BASE.address));
+ });
+ });
+
+ describe('the rest of the row survives the transform', () => {
+ it('should carry every receiver name through in sheet order', () => {
+ const { rows } = parseFixture();
+ payloads.forEach((p, i) => expect(p.customer_name).toBe(rows[i].firstname));
+ });
+
+ it('should send phone numbers as strings even though the sheet types them as numbers', () => {
+ payloads.forEach((p) => expect(typeof p.customer_phone).toBe('string'));
+ });
+
+ it('should expand quantity into one parcel entry per item', () => {
+ const { rows } = parseFixture();
+ payloads.forEach((p, i) => {
+ expect(p.parcels.length).toBe(Math.max(1, Number(rows[i].quantity) || 1));
+ });
+ });
+
+ it('should keep the COD rows, which must survive a base leg', () => {
+ // Three rows carry cash to collect. A parcel handed over at a base and
+ // carried onward still has money attached to it at the far end.
+ const { rows } = parseFixture();
+ const cod = rows.filter((r) => Number(r.collectionamt) > 0);
+ expect(cod.length).toBe(3);
+ });
+
+ it('should put the same delivery time on every booking in one run', () => {
+ payloads.forEach((p) => expect(p.deliverytime).toBe(DELIVERY_TIME));
+ });
+
+ it('should ignore the human-readable notes column rather than send it', () => {
+ // The sheet carries an "Expected Routing" column for whoever is running
+ // the test by hand. It must not reach the wire.
+ payloads.forEach((p) => {
+ expect(Object.keys(p).some((k) => k.toLowerCase().includes('expected'))).toBe(false);
+ });
+ });
+ });
+});
diff --git a/tests/lib/DataTable.test.jsx b/tests/lib/DataTable.test.jsx
new file mode 100644
index 0000000..0314af6
--- /dev/null
+++ b/tests/lib/DataTable.test.jsx
@@ -0,0 +1,119 @@
+import React, { useState } from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+jest.mock('lucide-react', () => ({
+ Check: () => ,
+ Minus: () => ,
+ ArrowDown: () => ,
+ ArrowUp: () => ,
+ ChevronsUpDown: () => ,
+ Inbox: () => ,
+ ChevronLeft: () => ,
+ ChevronRight: () => ,
+ ChevronsLeft: () => ,
+ ChevronsRight: () => ,
+}));
+
+import { DataTable } from '@/components/ds/DataTable';
+
+const COLUMNS = [
+ { key: 'id', header: 'ID' },
+ { key: 'title', header: 'Title' },
+];
+
+const createMockRows = (count = 60) =>
+ Array.from({ length: count }, (_, i) => ({
+ id: `order-${i + 1}`,
+ title: `Order #${i + 1}`,
+ }));
+
+function TestTableWrapper({
+ rowCount = 60,
+ pageSize = 25,
+ selectAllPages = true,
+ initialSelected = [],
+}) {
+ const [selectedIds, setSelectedIds] = useState(initialSelected);
+ const rows = createMockRows(rowCount);
+
+ return (
+
+ {selectedIds.length}
+ row.id}
+ pageSize={pageSize}
+ selectAllPages={selectAllPages}
+ selectedIds={selectedIds}
+ onSelectionChange={setSelectedIds}
+ />
+
+ );
+}
+
+describe('DataTable selection across pagination', () => {
+ it('should select ALL 60 rows across all pages when selectAllPages is true', () => {
+ render();
+
+ expect(screen.getByTestId('selected-count').textContent).toBe('0');
+
+ // Header checkbox for select all
+ const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i });
+ expect(selectAllCheckbox).not.toBeChecked();
+
+ // Click select all
+ fireEvent.click(selectAllCheckbox);
+
+ // All 60 rows should now be selected
+ expect(screen.getByTestId('selected-count').textContent).toBe('60');
+ expect(selectAllCheckbox).toBeChecked();
+
+ // Click again to unselect all
+ fireEvent.click(selectAllCheckbox);
+ expect(screen.getByTestId('selected-count').textContent).toBe('0');
+ expect(selectAllCheckbox).not.toBeChecked();
+ });
+
+ it('should select ONLY current page (25 rows) when selectAllPages is false', () => {
+ render();
+
+ expect(screen.getByTestId('selected-count').textContent).toBe('0');
+
+ const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows on this page/i });
+ fireEvent.click(selectAllCheckbox);
+
+ // Only the 25 rows on page 1 should be selected
+ expect(screen.getByTestId('selected-count').textContent).toBe('25');
+ });
+
+ it('should reflect indeterminate state when some rows are selected', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('selected-count').textContent).toBe('3');
+ const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i });
+
+ expect(selectAllCheckbox.getAttribute('data-state') === 'indeterminate' || selectAllCheckbox.indeterminate).toBeTruthy();
+ });
+
+ it('should toggle individual row selection without affecting other pages', () => {
+ render();
+
+ const rowCheckboxes = screen.getAllByRole('checkbox');
+ // First checkbox is header, second is row 1
+ const firstRowCheckbox = rowCheckboxes[1];
+
+ fireEvent.click(firstRowCheckbox);
+ expect(screen.getByTestId('selected-count').textContent).toBe('1');
+
+ fireEvent.click(firstRowCheckbox);
+ expect(screen.getByTestId('selected-count').textContent).toBe('0');
+ });
+});
diff --git a/tests/lib/bookingDrops.test.js b/tests/lib/bookingDrops.test.js
new file mode 100644
index 0000000..93d851c
--- /dev/null
+++ b/tests/lib/bookingDrops.test.js
@@ -0,0 +1,81 @@
+import { dropCountOf, dropSummary, isMultiDrop, plural } from '@/lib/bookingDrops';
+
+describe('dropCountOf', () => {
+ // The console can ship before or after the backend that adds these fields.
+ // An absent count must read as 0 so every multi-drop behaviour stays off and
+ // the page renders exactly as it did.
+ it('treats an absent destinationcount as zero', () => {
+ expect(dropCountOf({})).toBe(0);
+ expect(dropCountOf({ destinationcount: undefined })).toBe(0);
+ expect(dropCountOf(undefined)).toBe(0);
+ expect(dropCountOf(null)).toBe(0);
+ });
+
+ it('reads the count the backend sent', () => {
+ expect(dropCountOf({ destinationcount: 3 })).toBe(3);
+ expect(dropCountOf({ destinationcount: 1 })).toBe(1);
+ });
+
+ // A console-created booking has no bookingdestinations rows at all.
+ it('keeps a console-created booking at zero', () => {
+ expect(dropCountOf({ destinationcount: 0 })).toBe(0);
+ });
+});
+
+describe('isMultiDrop', () => {
+ // THE IMPORTANT ONE. This gate is what stops the Orders row rendering the
+ // booking's own consignmentstatus — which is the FIRST leg's status wearing
+ // the whole booking's name — as the status of a three-drop pickup.
+ it('is true only above one destination', () => {
+ expect(isMultiDrop({ destinationcount: 2 })).toBe(true);
+ expect(isMultiDrop({ destinationcount: 3 })).toBe(true);
+ });
+
+ it('is false for a single drop, so the row keeps todays behaviour', () => {
+ expect(isMultiDrop({ destinationcount: 1 })).toBe(false);
+ });
+
+ it('is false when the backend has not shipped the field yet', () => {
+ expect(isMultiDrop({})).toBe(false);
+ expect(isMultiDrop({ bookingid: 7, status: 'converted_to_consignment' })).toBe(false);
+ });
+
+ it('is false for a console-created booking reporting zero', () => {
+ expect(isMultiDrop({ destinationcount: 0 })).toBe(false);
+ });
+});
+
+describe('dropSummary', () => {
+ it('reads as the handoff specifies', () => {
+ expect(dropSummary({ destinationcount: 3, totalpackagecount: 4 })).toBe(
+ '3 destinations · 4 packages'
+ );
+ });
+
+ // Every destination carries at least one package, so a zero package total is
+ // a stale or partial response rather than a real booking. Falling back to the
+ // destination count keeps the line truthful instead of printing "0 packages".
+ it('falls back to the destination count when the package sum is missing', () => {
+ expect(dropSummary({ destinationcount: 3 })).toBe('3 destinations · 3 packages');
+ expect(dropSummary({ destinationcount: 2, totalpackagecount: 0 })).toBe(
+ '2 destinations · 2 packages'
+ );
+ });
+
+ it('pluralises both nouns independently', () => {
+ expect(dropSummary({ destinationcount: 2, totalpackagecount: 1 })).toBe(
+ '2 destinations · 1 package'
+ );
+ expect(dropSummary({ destinationcount: 1, totalpackagecount: 2 })).toBe(
+ '1 destination · 2 packages'
+ );
+ });
+});
+
+describe('plural', () => {
+ it('keeps the singular at exactly one', () => {
+ expect(plural(1, 'package')).toBe('1 package');
+ expect(plural(0, 'package')).toBe('0 packages');
+ expect(plural(2, 'destination')).toBe('2 destinations');
+ });
+});
diff --git a/tests/lib/customerAppBookings.test.js b/tests/lib/customerAppBookings.test.js
new file mode 100644
index 0000000..de132cf
--- /dev/null
+++ b/tests/lib/customerAppBookings.test.js
@@ -0,0 +1,173 @@
+import {
+ BOOKING_SOURCE,
+ BOOKING_TABS,
+ bookingsForTab,
+ customerAppBookings,
+ isCustomerAppBooking,
+ quotedPrice,
+ serviceOptionOf,
+ serviceType,
+} from '@/lib/customerAppBookings';
+
+/**
+ * Which bookings belong on the Bookings page.
+ *
+ * The page shows what customers booked in the app; Orders shows what this
+ * console created. `bookingsource` is the only field that separates them, and
+ * the trap is its second value: the API and routes were renamed from "CRM" to
+ * "express", but the STORED column value was deliberately left as
+ * `"CRM_Console"` because it is existing data rather than a label. Matching on
+ * "express" would silently show every console order on the customer page.
+ */
+
+const appBooking = (over = {}) => ({
+ bookingid: 1,
+ bookingno: 'BK-1',
+ bookingsource: 'Customer_App',
+ status: 'Pending_Pickup',
+ ...over,
+});
+
+const consoleBooking = (over = {}) => ({
+ bookingid: 2,
+ bookingno: 'BK-2',
+ bookingsource: 'CRM_Console',
+ status: 'Pending_Pickup',
+ ...over,
+});
+
+describe('isCustomerAppBooking', () => {
+ it('should accept a booking made in the app', () => {
+ expect(isCustomerAppBooking(appBooking())).toBe(true);
+ });
+
+ it('should reject a console-created booking', () => {
+ expect(isCustomerAppBooking(consoleBooking())).toBe(false);
+ });
+
+ it('should still reject the console value under its post-rename name', () => {
+ // The outward naming became "express"; the stored value never did. Anything
+ // that starts matching on "express" here has misunderstood that.
+ expect(BOOKING_SOURCE.CONSOLE).toBe('CRM_Console');
+ expect(isCustomerAppBooking({ bookingsource: 'express' })).toBe(false);
+ });
+
+ it('should tolerate casing and stray whitespace', () => {
+ // Compared against a raw database column, not a validated enum.
+ expect(isCustomerAppBooking({ bookingsource: ' customer_app ' })).toBe(true);
+ expect(isCustomerAppBooking({ bookingsource: 'CUSTOMER_APP' })).toBe(true);
+ });
+
+ it('should not claim a booking with no source', () => {
+ // Filing unknown rows under "customer app" would overstate B2C volume, and
+ // that is a number people make decisions on.
+ expect(isCustomerAppBooking({})).toBe(false);
+ expect(isCustomerAppBooking({ bookingsource: '' })).toBe(false);
+ expect(isCustomerAppBooking(null)).toBe(false);
+ expect(isCustomerAppBooking(undefined)).toBe(false);
+ });
+});
+
+describe('customerAppBookings', () => {
+ it('should keep only app bookings and drop console ones', () => {
+ const list = [appBooking({ bookingid: 1 }), consoleBooking({ bookingid: 2 }), appBooking({ bookingid: 3 })];
+ expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([3, 1]);
+ });
+
+ it('should order newest first', () => {
+ const list = [appBooking({ bookingid: 5 }), appBooking({ bookingid: 42 }), appBooking({ bookingid: 12 })];
+ expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([42, 12, 5]);
+ });
+
+ it('should not mutate the list it was given', () => {
+ // The same array is the Orders page's cache entry — sorting it in place
+ // would reorder another page's rows underneath it.
+ const list = [appBooking({ bookingid: 1 }), appBooking({ bookingid: 9 })];
+ customerAppBookings(list);
+ expect(list.map((b) => b.bookingid)).toEqual([1, 9]);
+ });
+
+ it('should return an empty list rather than throw on nothing', () => {
+ expect(customerAppBookings(undefined)).toEqual([]);
+ expect(customerAppBookings(null)).toEqual([]);
+ expect(customerAppBookings([])).toEqual([]);
+ });
+});
+
+describe('the price quote', () => {
+ const withOptions = (options) => appBooking({ serviceoptions: options });
+
+ it('should read the service option the booking carries', () => {
+ const b = withOptions([{ servicetype: 'Fast', estimatedprice: 210, createdat: '2026-09-01T10:00:00Z' }]);
+ expect(serviceType(b)).toBe('Fast');
+ expect(quotedPrice(b)).toBe(210);
+ });
+
+ it('should take the newest option when a booking has several', () => {
+ // Matches the backend's own `ORDER BY createdat DESC LIMIT 1`, so the page
+ // and GET /customer/bookings/{id}/price cannot disagree.
+ const b = withOptions([
+ { servicetype: 'Normal', estimatedprice: 100, createdat: '2026-09-01T10:00:00Z' },
+ { servicetype: 'Superfast', estimatedprice: 300, createdat: '2026-09-02T10:00:00Z' },
+ ]);
+ expect(serviceOptionOf(b).servicetype).toBe('Superfast');
+ expect(quotedPrice(b)).toBe(300);
+ });
+
+ it('should report no quote rather than a zero price', () => {
+ // A booking with no service option has no price. Rendering 0 would read as
+ // "free", which is a different claim.
+ expect(serviceOptionOf(appBooking())).toBeNull();
+ expect(serviceOptionOf(withOptions([]))).toBeNull();
+ expect(serviceType(appBooking())).toBe('');
+ });
+
+ it('should not throw on a missing booking', () => {
+ expect(() => quotedPrice(undefined)).not.toThrow();
+ expect(quotedPrice(undefined)).toBe(0);
+ });
+});
+
+describe('the status tabs', () => {
+ it('should let the All tab through untouched', () => {
+ const list = [appBooking({ status: 'Pending_Pickup' }), appBooking({ status: 'Cancelled' })];
+ expect(bookingsForTab(list, 'all')).toHaveLength(2);
+ });
+
+ it('should put an uncollected unassigned booking under Created tab', () => {
+ const list = [
+ appBooking({ bookingid: 1, status: 'Pending_Pickup' }),
+ appBooking({ bookingid: 2, status: 'Created' }),
+ appBooking({ bookingid: 3, status: 'Miler_Assigned' }),
+ ];
+ expect(bookingsForTab(list, 'created').map((b) => b.bookingid)).toEqual([1, 2]);
+ });
+
+ it('should put an assigned / in-progress booking under Rider Assigned tab', () => {
+ const list = [
+ appBooking({ bookingid: 1, status: 'Miler_Assigned' }),
+ appBooking({ bookingid: 2, status: 'Converted_To_Consignment' }),
+ appBooking({ bookingid: 3, status: 'Pending_Pickup' }),
+ ];
+ expect(bookingsForTab(list, 'assigned').map((b) => b.bookingid)).toEqual([1, 2]);
+ });
+
+ it('should match status case-insensitively', () => {
+ expect(bookingsForTab([appBooking({ status: 'CANCELLED' })], 'cancelled')).toHaveLength(1);
+ });
+
+ it('should fall back to showing everything for an unknown tab', () => {
+ // An unknown tab value can only come from a bug in the page. Falling back to
+ // the full list keeps the data visible; silently returning nothing would
+ // look identical to "this customer has no bookings", which is a lie the
+ // operator cannot see through.
+ expect(bookingsForTab([appBooking()], 'nonsense')).toHaveLength(1);
+ });
+
+ it('should give every tab a value and a label', () => {
+ BOOKING_TABS.forEach((t) => {
+ expect(t.value).toBeTruthy();
+ expect(t.label).toBeTruthy();
+ });
+ });
+});
diff --git a/tests/lib/hubForm.test.js b/tests/lib/hubForm.test.js
new file mode 100644
index 0000000..01b7588
--- /dev/null
+++ b/tests/lib/hubForm.test.js
@@ -0,0 +1,134 @@
+import { validateHubForm } from '@/lib/hubForm';
+
+/**
+ * A hub in this console is a BASE to a rider.
+ *
+ * The bug these tests exist to prevent is already in production data: of the 18
+ * hubs live today, one carries no address and no coordinates. Only `hubname`
+ * and `applocationid` were ever validated, so a base could be saved that the
+ * rider app can name but cannot route anyone to — Navigate does nothing without
+ * a pin, and a rider standing outside an industrial estate has nothing to read
+ * without an address.
+ *
+ * The backend hands six fields to the rider app for a base. Five of six is not
+ * "mostly there"; it is a rider with a parcel and no way to deliver it.
+ */
+
+const VALID = {
+ hubname: 'Coimbatore Gandhipuram Hub',
+ applocationid: 1,
+ address: '14 Avinashi Road, Peelamedu, Coimbatore',
+ pincode: '641004',
+ latitude: '11.0168',
+ longitude: '76.9558'
+};
+
+describe('validateHubForm', () => {
+ describe('a complete base', () => {
+ it('should accept a form carrying all six rider-facing fields', () => {
+ const { errors, isValid } = validateHubForm(VALID);
+ expect(errors).toEqual({});
+ expect(isValid).toBe(true);
+ });
+
+ it('should hand back coordinates already coerced, so the caller does not re-parse', () => {
+ const { values } = validateHubForm(VALID);
+ expect(values.latitude).toBe(11.0168);
+ expect(values.longitude).toBe(76.9558);
+ expect(values.applocationid).toBe(1);
+ });
+
+ it('should accept a southern or western hemisphere base', () => {
+ // Nothing in the range check may assume India.
+ expect(validateHubForm({ ...VALID, latitude: '-33.8688', longitude: '-70.6693' }).isValid).toBe(true);
+ });
+ });
+
+ describe('the fields that were never checked', () => {
+ it('should reject a base with no address, because the rider reads it at the gate', () => {
+ const { errors, isValid } = validateHubForm({ ...VALID, address: '' });
+ expect(isValid).toBe(false);
+ expect(errors.address).toBeTruthy();
+ });
+
+ it('should reject whitespace as an address', () => {
+ expect(validateHubForm({ ...VALID, address: ' ' }).errors.address).toBeTruthy();
+ });
+
+ it('should reject a base with no pincode', () => {
+ expect(validateHubForm({ ...VALID, pincode: '' }).errors.pincode).toBeTruthy();
+ });
+
+ it('should reject a base with no coordinates, because Navigate cannot work', () => {
+ const { errors, isValid } = validateHubForm({ ...VALID, latitude: '', longitude: '' });
+ expect(isValid).toBe(false);
+ expect(errors.latitude).toBeTruthy();
+ expect(errors.longitude).toBeTruthy();
+ });
+
+ it('should treat 0,0 as unset rather than as a location', () => {
+ // The previous code coerced an empty coordinate to 0 and sent it. 0,0 is
+ // in the Gulf of Guinea; it is always an unset value, never a base.
+ const { errors } = validateHubForm({ ...VALID, latitude: '0', longitude: '0' });
+ expect(errors.latitude).toBeTruthy();
+ expect(errors.longitude).toBeTruthy();
+ });
+
+ it('should reject a single missing coordinate, not just both', () => {
+ // Half a pin is no pin.
+ expect(validateHubForm({ ...VALID, latitude: '' }).isValid).toBe(false);
+ expect(validateHubForm({ ...VALID, longitude: '' }).isValid).toBe(false);
+ });
+
+ it('should reject coordinates outside the possible range', () => {
+ expect(validateHubForm({ ...VALID, latitude: '91' }).errors.latitude).toBeTruthy();
+ expect(validateHubForm({ ...VALID, latitude: '-91' }).errors.latitude).toBeTruthy();
+ expect(validateHubForm({ ...VALID, longitude: '181' }).errors.longitude).toBeTruthy();
+ expect(validateHubForm({ ...VALID, longitude: '-181' }).errors.longitude).toBeTruthy();
+ });
+
+ it('should reject a coordinate that is not a number at all', () => {
+ expect(validateHubForm({ ...VALID, latitude: 'near the flyover' }).errors.latitude).toBeTruthy();
+ });
+
+ it('should catch a swapped lat/lng pair for an Indian base', () => {
+ // 76.9 as a latitude is inside the Arctic Circle. The range check is the
+ // only thing standing between a transposed paste and a base in the sea.
+ expect(validateHubForm({ ...VALID, latitude: '76.9558', longitude: '11.0168' }).isValid).toBe(true);
+ // ...but a longitude beyond 90 in the latitude slot is caught outright.
+ expect(validateHubForm({ ...VALID, latitude: '176.9558' }).errors.latitude).toBeTruthy();
+ });
+ });
+
+ describe('the checks that already existed', () => {
+ it('should still require a name', () => {
+ expect(validateHubForm({ ...VALID, hubname: '' }).errors.hubname).toBeTruthy();
+ expect(validateHubForm({ ...VALID, hubname: ' ' }).errors.hubname).toBeTruthy();
+ });
+
+ it('should still require a numeric city id', () => {
+ expect(validateHubForm({ ...VALID, applocationid: '' }).errors.applocationid).toBeTruthy();
+ expect(validateHubForm({ ...VALID, applocationid: 'Coimbatore' }).errors.applocationid).toBeTruthy();
+ });
+
+ it('should accept city id 0, which is a valid id and not an absence', () => {
+ expect(validateHubForm({ ...VALID, applocationid: 0 }).errors.applocationid).toBeUndefined();
+ });
+ });
+
+ describe('robustness', () => {
+ it('should report every problem at once rather than one at a time', () => {
+ // An operator fixing a form field by field, submit by submit, is a worse
+ // experience than being told everything that is wrong.
+ const { errors } = validateHubForm({});
+ expect(Object.keys(errors).sort()).toEqual(
+ ['address', 'applocationid', 'hubname', 'latitude', 'longitude', 'pincode'].sort()
+ );
+ });
+
+ it('should not throw on an empty or missing form', () => {
+ expect(() => validateHubForm()).not.toThrow();
+ expect(validateHubForm().isValid).toBe(false);
+ });
+ });
+});
diff --git a/tests/lib/orderFlow.test.js b/tests/lib/orderFlow.test.js
new file mode 100644
index 0000000..3a9bb72
--- /dev/null
+++ b/tests/lib/orderFlow.test.js
@@ -0,0 +1,480 @@
+import {
+ ANCHOR_RADIUS_M,
+ DROP_TARGET,
+ FLOW,
+ PICKUP_SOURCE,
+ PICKUP_SOURCE_TYPE,
+ buildAnchors,
+ buildFlowFields,
+ classifyBooking,
+ flowForDraft,
+ flowLabel,
+ hasCustomerPickup,
+ hasHubDrop,
+ matchAnchor,
+ normalizeFlow
+} from '@/lib/orderFlow';
+
+/**
+ * Which end of a booking is a Doormile facility.
+ *
+ * The bug these tests exist to prevent: the console assumed the pickup was
+ * ALWAYS the hub. queries.js used the tenant's name as the collection point's
+ * identity, so every customer pickup for one client collapsed onto a single map
+ * pin placed at an arbitrary one of them, and the rider's polyline skipped the
+ * entire first-mile leg.
+ *
+ * Two invariants carry the most weight here and are asserted repeatedly:
+ *
+ * 1. UNKNOWABLE MEANS FORWARD. No anchors loaded, no coordinates, a 0,0
+ * "unset" — none of these may produce a customer pickup. An order that
+ * cannot be classified has to look exactly as it did before this module
+ * existed, or a data problem starts drawing phantom collection points.
+ * 2. A CUSTOMER PICKUP NEVER SHARES A GROUPING KEY. Two doorsteps are two
+ * places. Merging them is precisely the defect being replaced.
+ */
+
+// Real Coimbatore-ish coordinates, so the distances under test are the
+// magnitudes the radius was actually chosen for.
+const HUB = { hubid: 7, hubname: 'Gandhipuram Hub', latitude: 11.0168, longitude: 76.9558 };
+const KITCHEN = {
+ tenantlocationid: 31,
+ tenantid: 4,
+ locationname: '124 Gandhipuram Main Rd',
+ latitude: 11.021,
+ longitude: 76.962
+};
+
+// ~4 km and ~6 km out — unambiguously somebody's door.
+const DOOR_A = { lat: 11.05, lng: 77.0 };
+const DOOR_B = { lat: 11.06, lng: 77.01 };
+
+const anchors = buildAnchors({ hubs: [HUB], tenantLocations: [KITCHEN] });
+
+const booking = ({ id = 1, from, to, ...rest }) => ({
+ bookingid: id,
+ pickuplatitude: from.lat,
+ pickuplongitude: from.lng,
+ deliverylatitude: to.lat,
+ deliverylongitude: to.lng,
+ ...rest
+});
+
+const AT_HUB = { lat: HUB.latitude, lng: HUB.longitude };
+const AT_KITCHEN = { lat: KITCHEN.latitude, lng: KITCHEN.longitude };
+
+describe('orderFlow', () => {
+ describe('buildAnchors', () => {
+ it('should treat both a Doormile hub and a client location as facilities', () => {
+ expect(anchors).toHaveLength(2);
+ expect(anchors.map((a) => a.kind).sort()).toEqual(['hub', 'tenantlocation']);
+ });
+
+ it('should give each anchor a key that is unique per facility', () => {
+ expect(new Set(anchors.map((a) => a.key)).size).toBe(anchors.length);
+ });
+
+ it('should drop a facility nobody geocoded rather than keep it with NaN coordinates', () => {
+ const built = buildAnchors({ hubs: [{ hubid: 9, hubname: 'Unmapped' }, HUB] });
+ expect(built.map((a) => a.hubid)).toEqual([7]);
+ });
+
+ it('should return an empty set when called with nothing', () => {
+ expect(buildAnchors()).toEqual([]);
+ });
+
+ it('should give a tenant location the hub that services its city', () => {
+ // A tenant location record has NO hubid field — the API does not carry
+ // one. Without this, every order originating at a client's own kitchen
+ // went to the backend with no servicing hub, and zone attribution fell
+ // straight back to matching hub names against address text.
+ const kitchen = anchors.find((a) => a.kind === 'tenantlocation');
+ expect(kitchen.hubid).toBe(7);
+ });
+
+ it('should not invent a servicing hub for a location in another city', () => {
+ // Chennai, ~400 km away. A wrong hub id is worse than none: it files the
+ // order into a zone it has nothing to do with, where nobody is looking.
+ const [faraway] = buildAnchors({
+ hubs: [HUB],
+ tenantLocations: [{ tenantlocationid: 99, locationname: 'Chennai Depot', latitude: 13.0827, longitude: 80.2707 }]
+ }).filter((a) => a.kind === 'tenantlocation');
+ expect(faraway.hubid).toBeNull();
+ });
+
+ it("should keep a location's own hubid when the record already has one", () => {
+ const [explicit] = buildAnchors({
+ hubs: [HUB],
+ tenantLocations: [{ ...KITCHEN, hubid: 12 }]
+ }).filter((a) => a.kind === 'tenantlocation');
+ expect(explicit.hubid).toBe(12);
+ });
+
+ it('should leave hubid null when no hubs were loaded at all', () => {
+ const [alone] = buildAnchors({ tenantLocations: [KITCHEN] });
+ expect(alone.hubid).toBeNull();
+ });
+ });
+
+ describe('matchAnchor', () => {
+ it('should match a point sitting on a facility', () => {
+ expect(matchAnchor(HUB.latitude, HUB.longitude, anchors)?.hubid).toBe(7);
+ });
+
+ it('should not match a customer door several km away', () => {
+ expect(matchAnchor(DOOR_A.lat, DOOR_A.lng, anchors)).toBeNull();
+ });
+
+ it('should choose the NEAREST facility, not the first one in the array', () => {
+ // Two facilities sharing a compound is normal. Picking by array order
+ // would name the same pin differently depending on what /admin/hubs
+ // happened to return first.
+ const twinA = { hubid: 1, hubname: 'Sorting Centre', latitude: 11.0168, longitude: 76.9558 };
+ const twinB = { hubid: 2, hubname: 'Client Depot', latitude: 11.01685, longitude: 76.95585 };
+ const both = buildAnchors({ hubs: [twinA, twinB] });
+ expect(matchAnchor(twinB.latitude, twinB.longitude, both)?.hubid).toBe(2);
+ expect(matchAnchor(twinA.latitude, twinA.longitude, both)?.hubid).toBe(1);
+ });
+
+ it('should never match a 0,0 "unset" coordinate against a real facility', () => {
+ expect(matchAnchor(0, 0, anchors)).toBeNull();
+ });
+
+ it('should treat the radius as a boundary in metres, not degrees', () => {
+ // ~100 m north of the hub: inside. ~500 m: outside.
+ const inside = HUB.latitude + 0.0009;
+ const outside = HUB.latitude + 0.0045;
+ expect(matchAnchor(inside, HUB.longitude, anchors)).not.toBeNull();
+ expect(matchAnchor(outside, HUB.longitude, anchors)).toBeNull();
+ expect(ANCHOR_RADIUS_M).toBe(200);
+ });
+ });
+
+ describe('classifyBooking — the four flows', () => {
+ it('should classify hub to customer as Forward', () => {
+ const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
+ expect(r.flow).toBe(FLOW.FORWARD);
+ expect(r.pickupSource).toBe(PICKUP_SOURCE.HUB);
+ expect(r.dropTarget).toBe(DROP_TARGET.CUSTOMER);
+ });
+
+ it('should classify customer to hub as Reverse — the customer pickup', () => {
+ const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
+ expect(r.flow).toBe(FLOW.REVERSE);
+ expect(r.pickupSource).toBe(PICKUP_SOURCE.CUSTOMER);
+ expect(r.dropTarget).toBe(DROP_TARGET.HUB);
+ });
+
+ it('should classify customer to customer as P2P', () => {
+ const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors);
+ expect(r.flow).toBe(FLOW.P2P);
+ });
+
+ it('should classify hub to hub as Linehaul', () => {
+ const r = classifyBooking(booking({ from: AT_KITCHEN, to: AT_HUB }), anchors);
+ expect(r.flow).toBe(FLOW.LINEHAUL);
+ });
+ });
+
+ describe('classifyBooking — unknowable input stays Forward', () => {
+ it('should fall back to Forward when no facility list has loaded', () => {
+ // Geometry proves nothing without anchors. Anything but Forward here
+ // would relabel the whole board during the first render, before
+ // /admin/hubs has answered.
+ expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), []).flow).toBe(FLOW.FORWARD);
+ expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B })).flow).toBe(FLOW.FORWARD);
+ });
+
+ it('should fall back to Forward for a pickup that failed to geocode', () => {
+ // 0,0 is the backend's "unset". A pickup that never geocoded is a data
+ // problem, not a doorstep collection — calling it one would put a
+ // phantom violet pin on the map off the coast of Africa.
+ const unset = classifyBooking(booking({ id: 9, from: { lat: 0, lng: 0 }, to: DOOR_A }), anchors);
+ expect(unset.flow).toBe(FLOW.FORWARD);
+
+ const missing = classifyBooking({ bookingid: 10, deliverylatitude: DOOR_A.lat, deliverylongitude: DOOR_A.lng }, anchors);
+ expect(missing.flow).toBe(FLOW.FORWARD);
+ });
+
+ it('should not crash on a null or empty booking', () => {
+ expect(classifyBooking(null, anchors).flow).toBe(FLOW.FORWARD);
+ expect(classifyBooking({}, anchors).flow).toBe(FLOW.FORWARD);
+ });
+ });
+
+ describe('classifyBooking — an explicit ordertype wins over geometry', () => {
+ it('should trust a stored ordertype even when the coordinates disagree', () => {
+ // Coordinates say Forward; the record says otherwise. Once the backend
+ // persists the field it is the authority, because it records what the
+ // operator actually booked.
+ const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A, ordertype: 'Reverse' }), anchors);
+ expect(r.flow).toBe(FLOW.REVERSE);
+ expect(r.derived).toBe(false);
+ });
+
+ it('should mark a geometry-decided classification as derived', () => {
+ expect(classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors).derived).toBe(true);
+ });
+
+ it('should fall through to geometry when the stored value means nothing', () => {
+ const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB, ordertype: 'banana' }), anchors);
+ expect(r.flow).toBe(FLOW.REVERSE);
+ expect(r.derived).toBe(true);
+ });
+ });
+
+ describe('classifyBooking — the pickup point grouping key', () => {
+ it('should give every order off one facility the SAME key', () => {
+ const a = classifyBooking(booking({ id: 1, from: AT_KITCHEN, to: DOOR_A }), anchors);
+ const b = classifyBooking(booking({ id: 2, from: AT_KITCHEN, to: DOOR_B }), anchors);
+ expect(a.pickupPointKey).toBe(b.pickupPointKey);
+ });
+
+ it('should give every customer pickup its OWN key', () => {
+ // The defect being replaced: these two collapsed onto one map pin
+ // because both resolved to the tenant's name.
+ const a = classifyBooking(booking({ id: 1, from: DOOR_A, to: AT_HUB }), anchors);
+ const b = classifyBooking(booking({ id: 2, from: DOOR_B, to: AT_HUB }), anchors);
+ expect(a.pickupPointKey).not.toBe(b.pickupPointKey);
+ });
+
+ it('should name a facility pickup after the facility and a customer pickup after its address', () => {
+ const hub = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
+ expect(hub.pickupPointName).toBe('124 Gandhipuram Main Rd');
+
+ const door = classifyBooking(
+ booking({ from: DOOR_A, to: AT_HUB, pickupaddress: '22 Ponvizha Nagar' }),
+ anchors
+ );
+ expect(door.pickupPointName).toBe('22 Ponvizha Nagar');
+ });
+ });
+
+ describe('classifyBooking — servicing hub', () => {
+ it('should prefer an id the booking already carries', () => {
+ const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B, sourcehubid: 42 }), anchors);
+ expect(r.servicingHubId).toBe(42);
+ });
+
+ it('should fall back to the DESTINATION hub on a customer pickup', () => {
+ // Nothing at the pickup end names a facility, but an inbound collection
+ // plainly belongs to the hub it is being brought to. Without this, every
+ // customer pickup would lose its zone.
+ const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
+ expect(r.servicingHubId).toBe(7);
+ });
+
+ it('should resolve through a tenant location, not only through a Doormile hub', () => {
+ // The case that was silently broken: the pickup matches the client's own
+ // kitchen, which is not itself a hub. Before the kitchen inherited a
+ // servicing hub this returned null and the order reached the backend
+ // with no zone.
+ const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
+ expect(r.originAnchor.kind).toBe('tenantlocation');
+ expect(r.servicingHubId).toBe(7);
+ });
+
+ it('should be null on a P2P run where neither end is a facility', () => {
+ // Deliberate: a fabricated hub id here would file the order under a zone
+ // it has nothing to do with. Null lets ZoneContext fall back to radius.
+ expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors).servicingHubId).toBeNull();
+ });
+ });
+
+ describe('normalizeFlow', () => {
+ it.each([
+ ['reverse', FLOW.REVERSE],
+ ['REVERSE', FLOW.REVERSE],
+ ['customer_pickup', FLOW.REVERSE],
+ ['customer pickup', FLOW.REVERSE],
+ ['customer-pickup', FLOW.REVERSE],
+ ['hub_pickup', FLOW.FORWARD],
+ ['outbound', FLOW.FORWARD],
+ ['p2p', FLOW.P2P],
+ ['point_to_point', FLOW.P2P],
+ ['hyperlocal', FLOW.P2P],
+ ['line_haul', FLOW.LINEHAUL]
+ ])('should read %s as %s', (input, expected) => {
+ expect(normalizeFlow(input)).toBe(expected);
+ });
+
+ it('should return null for anything it does not recognise, never throw', () => {
+ [undefined, null, '', ' ', 'banana', 42, {}].forEach((value) => {
+ expect(normalizeFlow(value)).toBeNull();
+ });
+ });
+ });
+
+ describe('flow predicates', () => {
+ it('should identify which flows require travelling out to collect', () => {
+ expect(hasCustomerPickup(FLOW.REVERSE)).toBe(true);
+ expect(hasCustomerPickup(FLOW.P2P)).toBe(true);
+ expect(hasCustomerPickup(FLOW.FORWARD)).toBe(false);
+ expect(hasCustomerPickup(FLOW.LINEHAUL)).toBe(false);
+ });
+
+ it('should identify which flows end at a facility', () => {
+ expect(hasHubDrop(FLOW.REVERSE)).toBe(true);
+ expect(hasHubDrop(FLOW.LINEHAUL)).toBe(true);
+ expect(hasHubDrop(FLOW.FORWARD)).toBe(false);
+ expect(hasHubDrop(FLOW.P2P)).toBe(false);
+ });
+
+ it('should label every flow, and fall back rather than render blank', () => {
+ Object.values(FLOW).forEach((f) => expect(flowLabel(f)).toBeTruthy());
+ expect(flowLabel(FLOW.REVERSE)).toBe('Customer Pickup');
+ expect(flowLabel('nonsense')).toBe(flowLabel(FLOW.FORWARD));
+ });
+ });
+
+ describe('flowForDraft — what the create form books', () => {
+ it('should book a hub pickup to a typed address as Forward', () => {
+ const r = flowForDraft({
+ pickupSource: PICKUP_SOURCE.HUB,
+ dropLat: DOOR_A.lat,
+ dropLng: DOOR_A.lng,
+ anchors
+ });
+ expect(r.flow).toBe(FLOW.FORWARD);
+ });
+
+ it('should recognise a drop that lands on a hub as a return, not a delivery', () => {
+ // Without measuring the drop end, a rider bringing a parcel back to the
+ // hub would be booked P2P and routed as if the hub were a house.
+ const r = flowForDraft({
+ pickupSource: PICKUP_SOURCE.CUSTOMER,
+ dropLat: HUB.latitude,
+ dropLng: HUB.longitude,
+ anchors
+ });
+ expect(r.flow).toBe(FLOW.REVERSE);
+ expect(r.destAnchor?.hubid).toBe(7);
+ });
+
+ it('should book a customer pickup to a customer address as P2P', () => {
+ const r = flowForDraft({
+ pickupSource: PICKUP_SOURCE.CUSTOMER,
+ dropLat: DOOR_B.lat,
+ dropLng: DOOR_B.lng,
+ anchors
+ });
+ expect(r.flow).toBe(FLOW.P2P);
+ });
+
+ it('should default to a hub pickup when the form has told it nothing', () => {
+ expect(flowForDraft().flow).toBe(FLOW.FORWARD);
+ });
+ });
+
+ describe('buildFlowFields — what goes on the wire', () => {
+ it('should always name the flow', () => {
+ expect(buildFlowFields({ flow: FLOW.REVERSE }).ordertype).toBe(FLOW.REVERSE);
+ });
+
+ it('should write BOTH hub id fields, which the console has only ever read', () => {
+ // queries.js has always read b.hubid / b.sourcehubid and the console has
+ // never written either, which is why zone attribution had to fall back to
+ // matching hub names against concatenated address text.
+ const fields = buildFlowFields({ flow: FLOW.FORWARD, servicingHubId: 7, tenantLocationId: 31 });
+ expect(fields.sourcehubid).toBe(7);
+ expect(fields.hubid).toBe(7);
+ expect(fields.tenantlocationid).toBe(31);
+ });
+
+ it('should omit ids it was not given rather than send null', () => {
+ // pickup_source_type is NOT an id and is always present — see the next
+ // block for why an omission would say the wrong thing.
+ expect(buildFlowFields({ flow: FLOW.P2P })).toEqual({
+ ordertype: FLOW.P2P,
+ pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
+ });
+ expect(buildFlowFields({ flow: FLOW.P2P, servicingHubId: '' })).toEqual({
+ ordertype: FLOW.P2P,
+ pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
+ });
+ });
+
+ it('should fall back to Forward rather than put an invented type on the wire', () => {
+ expect(buildFlowFields({ flow: 'banana' }).ordertype).toBe(FLOW.FORWARD);
+ expect(buildFlowFields().ordertype).toBe(FLOW.FORWARD);
+ });
+ });
+
+ describe('pickup_source_type — what kind of place the parcel leaves', () => {
+ const baseAnchor = anchors.find((a) => a.kind === 'hub');
+ const siteAnchor = anchors.find((a) => a.kind === 'tenantlocation');
+
+ it('should name a base pickup and carry the base id', () => {
+ const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: baseAnchor });
+ expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
+ expect(fields.pickuphubid).toBe(HUB.hubid);
+ });
+
+ it("should call a client's own site a merchant, not a base", () => {
+ // Both are facilities to the geometry, but a rider arriving at a kitchen
+ // is not arriving at a base — different signage, different person to ask
+ // for. Collapsing them is what made every logistics pickup read as the
+ // rider's own base.
+ const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: siteAnchor });
+ expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT);
+ expect(fields.pickuphubid).toBeUndefined();
+ });
+
+ it('should send "customer" as a value rather than leaving it off', () => {
+ // An omission reads as "nobody filled this in", which is a different fact
+ // from "this is somebody's front door".
+ expect(buildFlowFields({ flow: FLOW.P2P, pickupAnchor: null }).pickup_source_type).toBe(
+ PICKUP_SOURCE_TYPE.CUSTOMER
+ );
+ });
+
+ it('should never put an unknown source type on the wire', () => {
+ const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupSourceType: 'banana', pickupAnchor: baseAnchor });
+ expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
+ });
+
+ it('should not name a base on a pickup that is not from one', () => {
+ const fields = buildFlowFields({
+ flow: FLOW.REVERSE,
+ pickupSourceType: PICKUP_SOURCE_TYPE.CUSTOMER,
+ pickupAnchor: baseAnchor
+ });
+ expect(fields.pickuphubid).toBeUndefined();
+ });
+
+ it('should prefer the type stored on the booking over geometry', () => {
+ // A booking collected at a base whose coordinates were later edited must
+ // keep reading as a base pickup — the row recorded what actually happened.
+ const c = classifyBooking(
+ {
+ bookingid: 1,
+ pickup_source_type: PICKUP_SOURCE_TYPE.HUB,
+ pickuphubid: HUB.hubid,
+ pickuplatitude: DOOR_A.lat,
+ pickuplongitude: DOOR_A.lng,
+ deliverylatitude: DOOR_B.lat,
+ deliverylongitude: DOOR_B.lng
+ },
+ anchors
+ );
+ expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
+ expect(c.pickupHubId).toBe(HUB.hubid);
+ expect(c.sourceTypeFromServer).toBe(true);
+ });
+
+ it('should classify an older booking from geometry when the row says nothing', () => {
+ const c = classifyBooking(
+ {
+ bookingid: 2,
+ pickuplatitude: HUB.latitude,
+ pickuplongitude: HUB.longitude,
+ deliverylatitude: DOOR_B.lat,
+ deliverylongitude: DOOR_B.lng
+ },
+ anchors
+ );
+ expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
+ expect(c.sourceTypeFromServer).toBe(false);
+ });
+ });
+});
diff --git a/tests/lib/routingSummary.test.js b/tests/lib/routingSummary.test.js
new file mode 100644
index 0000000..6684f52
--- /dev/null
+++ b/tests/lib/routingSummary.test.js
@@ -0,0 +1,180 @@
+import {
+ baseLine,
+ nextActionLabel,
+ routingReason,
+ routingRoute,
+ summariseRouting
+} from '@/lib/routingSummary';
+
+/**
+ * The routing block, turned into sentences.
+ *
+ * This is what a support agent reads to a rider who has rung asking why his
+ * screen says hand over at a base instead of deliver to the customer. Two
+ * things carry the weight here:
+ *
+ * 1. A PROJECTION MUST NOT READ AS A DECISION. Before pickup nothing has been
+ * routed; `decided: false` means the answer is what WILL happen. Stating a
+ * forecast as a fact is a wrong answer delivered confidently.
+ * 2. THE WIRE SAYS HUB, PEOPLE SAY BASE. The translation happens at this edge
+ * and nowhere else, and it never travels back up the wire.
+ */
+
+const HUB_ROUTED = {
+ pickup_source_type: 'customer',
+ pickup_source_name: 'Anitha R',
+ from_address: '12 Race Course Road, Coimbatore',
+ from_pincode: '641018',
+ to_address: '44 Mount Road, Chennai',
+ destination_pincode: '600002',
+ is_hyperlocal: false,
+ consignment_state: 'Created',
+ next_action: 'inward_at_hub',
+ next_hub: {
+ id: 1,
+ name: 'Coimbatore Hub',
+ address: '14 Avinashi Road, Peelamedu, Coimbatore',
+ pincode: '641004',
+ latitude: 11.0272,
+ longitude: 76.9905
+ },
+ inwardedat: null,
+ decided: true
+};
+
+const HYPERLOCAL = {
+ ...HUB_ROUTED,
+ destination_pincode: '641004',
+ is_hyperlocal: true,
+ consignment_state: 'Out_for_Delivery',
+ next_action: 'deliver',
+ next_hub: null
+};
+
+describe('routingReason — why it is going that way', () => {
+ it('should say a base is involved when the areas differ', () => {
+ expect(routingReason(HUB_ROUTED)).toMatch(/through a base/i);
+ });
+
+ it('should say the rider carries it straight there when the areas match', () => {
+ expect(routingReason(HYPERLOCAL)).toMatch(/straight to the receiver/i);
+ });
+
+ it('should never say "hub" to an operator', () => {
+ // Internal vocabulary. Screens say base.
+ expect(routingReason(HUB_ROUTED).toLowerCase()).not.toContain('hub');
+ });
+
+ it('should return an empty string rather than throw when there is no routing block', () => {
+ // An older booking, or a backend that has not shipped this yet.
+ expect(routingReason(null)).toBe('');
+ expect(routingReason(undefined)).toBe('');
+ });
+});
+
+describe('routingRoute — the from → to line', () => {
+ it('should show both pincodes', () => {
+ expect(routingRoute(HUB_ROUTED)).toContain('641018');
+ expect(routingRoute(HUB_ROUTED)).toContain('600002');
+ });
+
+ it('should not caveat a decision that has actually been taken', () => {
+ expect(routingRoute(HUB_ROUTED)).not.toMatch(/not yet collected/i);
+ });
+
+ it('should mark an undecided booking as a forecast, not a fact', () => {
+ const undecided = { ...HUB_ROUTED, decided: false };
+ expect(routingRoute(undecided)).toMatch(/not yet collected/i);
+ expect(routingRoute(undecided)).toMatch(/will happen, not what has/i);
+ });
+
+ it('should show a dash for a pincode the booking never carried', () => {
+ // The delivery-pincode column is genuinely empty on plenty of rows; a blank
+ // gap in the line reads as a rendering bug rather than as missing data.
+ expect(routingRoute({ ...HUB_ROUTED, destination_pincode: '' })).toContain('—');
+ });
+});
+
+describe('nextActionLabel — what the rider does next', () => {
+ it('should translate every action the backend can send', () => {
+ expect(nextActionLabel('pickup')).toMatch(/collect/i);
+ expect(nextActionLabel('inward_at_hub')).toMatch(/base/i);
+ expect(nextActionLabel('start_delivery')).toMatch(/delivery run/i);
+ expect(nextActionLabel('deliver')).toMatch(/receiver/i);
+ expect(nextActionLabel('handed_to_hub')).toMatch(/handed over/i);
+ expect(nextActionLabel('none')).toMatch(/nothing further/i);
+ });
+
+ it('should render inward_at_hub as a BASE handover, never as a hub one', () => {
+ expect(nextActionLabel('inward_at_hub').toLowerCase()).not.toContain('hub');
+ });
+
+ it('should show an unknown action as itself rather than swallow it', () => {
+ // The backend may add actions. A blank row tells the operator nothing; the
+ // raw word at least tells them something new exists.
+ expect(nextActionLabel('return_to_sender')).toBe('return_to_sender');
+ });
+
+ it('should show a dash when there is no action at all', () => {
+ expect(nextActionLabel(undefined)).toBe('—');
+ expect(nextActionLabel('')).toBe('—');
+ });
+});
+
+describe('baseLine — naming the base', () => {
+ it('should read the base with its pincode', () => {
+ expect(baseLine(HUB_ROUTED.next_hub)).toBe('Coimbatore Hub · 641004');
+ });
+
+ it('should fall back to the name alone when a base has no pincode', () => {
+ expect(baseLine({ name: 'Coimbatore Hub' })).toBe('Coimbatore Hub');
+ });
+
+ it('should be null when no base is involved', () => {
+ // A hyperlocal parcel has no base. That is the correct answer, not a gap.
+ expect(baseLine(null)).toBeNull();
+ expect(baseLine({})).toBeNull();
+ });
+});
+
+describe('summariseRouting — everything the drawer renders', () => {
+ it('should carry the base, its address and the next step for a hub-routed parcel', () => {
+ const s = summariseRouting(HUB_ROUTED);
+ expect(s.base).toBe('Coimbatore Hub · 641004');
+ expect(s.baseAddress).toBe('14 Avinashi Road, Peelamedu, Coimbatore');
+ expect(s.nextAction).toMatch(/base/i);
+ expect(s.consignmentState).toBe('Created');
+ expect(s.decided).toBe(true);
+ });
+
+ it('should carry no base for a hyperlocal parcel', () => {
+ const s = summariseRouting(HYPERLOCAL);
+ expect(s.base).toBeNull();
+ expect(s.baseAddress).toBeNull();
+ expect(s.nextAction).toMatch(/receiver/i);
+ });
+
+ it('should surface the received-at time once the parcel is actually inwarded', () => {
+ const s = summariseRouting({
+ ...HUB_ROUTED,
+ consignment_state: 'Inwarded_at_Hub',
+ next_action: 'handed_to_hub',
+ inwardedat: '2026-09-02T14:22:10Z'
+ });
+ expect(s.inwardedAt).toBe('2026-09-02T14:22:10Z');
+ expect(s.nextAction).toMatch(/handed over/i);
+ });
+
+ it('should show a dash for a parcel that has no consignment yet', () => {
+ const s = summariseRouting({ ...HUB_ROUTED, consignment_state: '', decided: false });
+ expect(s.consignmentState).toBe('—');
+ expect(s.decided).toBe(false);
+ });
+
+ it('should be null when the booking carries no routing block, so the panel hides', () => {
+ // An older backend, or a booking fetched before this shipped. The drawer
+ // renders nothing rather than a panel of dashes.
+ expect(summariseRouting(null)).toBeNull();
+ expect(summariseRouting(undefined)).toBeNull();
+ });
+});
|