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