// ==============================|| Bulk-order sheet columns ||============================== // // // The single definition of what a bulk-order spreadsheet may look like. Two // consumers share it: // // • pages/nearle/orders/multipleOrders.js — the console's bulk-upload page, // where this map originated. // • pages/nearle/assistant/bulkFile.js — Doormile AI's file upload. // // It lives here so a sheet that uploads on the page also uploads in the bot, // permanently. Copying it into the assistant was the alternative and was // rejected for the obvious reason: five pages once carried private STATUS_META // copies that drifted into disagreeing about the same order's label. // // SHEET_HEADER_MAP is reproduced from the page byte for byte, including the `*` // suffix convention (a starred header is required) and the note about which // mappings were never confirmed against a live tenant sheet. Do not "tidy" the // keys — they are the tenant's actual column titles, spaces and all. export const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, ''); // A receiver name arrives prefixed with the sheet's own row numbering // ("12. Ravi Kumar") often enough that stripping it is part of parsing. export const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name); export const SHEET_HEADER_MAP = { 'pickupdate(yyyy-mmm-dd)': 'date', 'sendername*': 'locationname', 'senderphone*': 'locationcontact', 'senderaddress*': 'locationaddress', 'receivername*': 'firstname', receiverphone: 'contactno', 'receiveralternatephone*': 'altcontactno', receiverfulladdress: 'address', // Best-effort — no live sample sheet to confirm these are the tenant's // actual column headers. Without them deliverycity/deliverypincode in // the submit payload were always sent blank; if these names are wrong, // behavior is unchanged from before (still blank), not worse. receivercity: 'city', receiverpincode: 'postcode', receiverlatitude: 'latitude', receiverlongitude: 'longitude', 'itemdescription*': 'description', Quantity: 'quantity', ' Collect Cash': 'collectionamt' }; // Starred headers are the ones the page reports as missing. export const requiredSheetColumns = () => Object.keys(SHEET_HEADER_MAP).filter((k) => k.trim().endsWith('*')); // ---- assistant-side translation --------------------------------------------- // // The page consumes the internal names above (`firstname`, `contactno`, …) // because its row editors were built on them. The assistant's rows are shaped // like a booking payload instead, so the two are bridged here rather than in // either consumer. const INTERNAL_TO_ROW = { firstname: 'customer_name', contactno: 'customer_phone', address: 'deliveryaddress', postcode: 'deliverypincode', city: 'deliverycity', latitude: 'deliverylatitude', longitude: 'deliverylongitude', description: 'itemdescription', quantity: 'quantity' }; // Headers someone typing their own sheet actually writes. Accepted in addition // to the tenant's official titles, never instead of them. // // `collectionamt` (the sheet's "Collect Cash") is deliberately NOT treated as a // price: it is cash to collect from the recipient, while `finalprice` is what // the delivery costs. Mapping one onto the other would quietly bill the wrong // number on every row. const PLAIN_ALIASES = { customer_name: ['name', 'customername', 'customer', 'recipient', 'recipientname', 'receiver', 'to'], customer_phone: ['phone', 'mobile', 'phonenumber', 'mobilenumber', 'customerphone', 'contact', 'recipientphone'], deliveryaddress: ['address', 'deliveryaddress', 'dropaddress', 'fulladdress', 'destination'], deliverypincode: ['pincode', 'pin', 'postcode', 'zip', 'zipcode', 'deliverypincode'], deliverycity: ['city', 'town', 'deliverycity'], deliverylatitude: ['latitude', 'lat', 'deliverylatitude'], deliverylongitude: ['longitude', 'lng', 'lon', 'deliverylongitude'], itemdescription: ['description', 'item', 'itemdescription', 'contents', 'goods', 'particulars'], itemcategory: ['category', 'itemcategory', 'type'], quantity: ['quantity', 'qty', 'parcels', 'pieces', 'count'], finalprice: ['price', 'amount', 'charge', 'deliverycharge', 'finalprice', 'rate', 'fare'], weight: ['weight', 'kg', 'kgs'] }; // normalised header → assistant row field. Built once; the tenant's official // titles win over a plain alias if a sheet somehow carries both. const ROW_FIELD_BY_HEADER = (() => { const out = {}; Object.entries(PLAIN_ALIASES).forEach(([field, headers]) => { headers.forEach((h) => { out[normalizeHeader(h)] = field; }); }); Object.entries(SHEET_HEADER_MAP).forEach(([sheetHeader, internal]) => { const field = INTERNAL_TO_ROW[internal]; if (!field) return; out[normalizeHeader(sheetHeader)] = field; out[normalizeHeader(sheetHeader).replace(/\*+$/, '')] = field; }); return out; })(); // The `*` in a sheet title is a "this one is required" annotation the tenant // types, not part of the column's name — so `Receiver Phone*` and // `ReceiverPhone` are the same column and both must resolve. Only the // assistant's lookup is star-tolerant; normalizeHeader itself is untouched, // because multipleOrders.js derives its missing-required warning from the star // and loosening that would change the page's behaviour. const unstar = (h) => normalizeHeader(h).replace(/\*+$/, ''); export const rowFieldForHeader = (header) => ROW_FIELD_BY_HEADER[normalizeHeader(header)] || ROW_FIELD_BY_HEADER[unstar(header)] || null; // Every field the assistant can fill from a sheet, for the downloadable template. export const TEMPLATE_HEADERS = [ 'Receiver Name*', 'Receiver Phone*', 'Receiver Full Address*', 'Receiver Pincode*', 'Receiver City', 'Item Description*', 'Quantity', 'Price' ]; // ---- one sheet row → one assistant row -------------------------------------- // // Unrecognised columns are RETURNED, not dropped silently: an operator whose // price column is titled something unexpected needs to be told it was ignored, // or they'll submit 200 orders priced from a column that was never read. export const mapSheetRow = (raw) => { const row = {}; const ignored = []; Object.entries(raw || {}).forEach(([header, value]) => { const field = rowFieldForHeader(header); if (!field) { if (String(header || '').trim() && String(value ?? '').trim()) ignored.push(String(header).trim()); return; } row[field] = field === 'customer_name' ? cleanReceiverName(value) : value; }); return { row, ignored }; };