Files
doormilxpress_astryx/tests/integration/logisticsBulkUpload.test.js

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);
});
});
});
});