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