299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|