updates on the bookings page and updated ai and more thngs
This commit is contained in:
459
tests/integration/bulkUpload.test.js
Normal file
459
tests/integration/bulkUpload.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
298
tests/integration/logisticsBulkUpload.test.js
Normal file
298
tests/integration/logisticsBulkUpload.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user