import Papa from 'papaparse'; import * as XLSX from 'xlsx'; import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns'; // ==============================|| Doormile AI — bulk order file upload ||============================== // // // Turns a CSV / XLS / XLSX into the SAME row array `parseBulkRows` produces from // a paste, so everything downstream — geocoding, validation, review, the chunked // submit, the per-row outcome report — is untouched by where the rows came from. // // Both parsers are already dependencies (`papaparse`, `xlsx`) and both are the // ones multipleOrders.js uses, as is the column map. A sheet that uploads on // that page uploads here. // // Three reporting rules, all of them about not lying by omission: // // • An unparseable row becomes a REPORTED error with its line number, never a // silently skipped line. A bulk import that quietly drops row 14 is worse // than one that refuses outright. // • Columns that were not recognised are NAMED. An operator whose price // column is titled something unexpected has to be told it was ignored, or // they'll submit 200 orders priced from a column nothing ever read. // • Rows duplicated inside the file are flagged BEFORE submit. The bulk // endpoint has no idempotency key, so a duplicate that gets through is a // second real rider dispatched to the same door. const CSV_EXT = /\.csv$/i; const EXCEL_EXT = /\.xlsx?$/i; const digits = (v) => String(v ?? '').replace(/\D/g, ''); const text = (v) => String(v ?? '').trim(); // A sheet cell can be a number, a date, or padded text — normalise to the same // shapes parseBulkRows yields so validateBulkRow behaves identically. const shapeRow = (raw, line) => { const { row, ignored } = mapSheetRow(raw); const lat = Number(row.deliverylatitude); const lng = Number(row.deliverylongitude); const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0; return { ignored, row: { line, customer_name: text(row.customer_name), // A 10-digit Indian mobile arrives as 9812345678, 09812345678, +91 // 98123 45678, or — from Excel — 9812345678 as a float. Strip to digits // and drop a leading country/trunk prefix the same way the single-order // flow does. customer_phone: digits(row.customer_phone).replace(/^(?:0|91)(?=\d{10}$)/, ''), deliveryaddress: text(row.deliveryaddress), deliverypincode: digits(row.deliverypincode), deliverycity: text(row.deliverycity), // Blank is meaningful: it means "quote this row from the tenant's pricing // row and the routed distance", the same as the single-order flow. It is // NOT zero. finalprice: text(row.finalprice), itemdescription: text(row.itemdescription) || 'Order', itemcategory: text(row.itemcategory) || 'General', quantity: Math.max(1, Number(row.quantity) || 1), weight: text(row.weight), // Coordinates from the sheet let the geocode pass skip this row entirely, // which on a 200-row file is the difference between minutes and seconds. ...(hasCoords ? { deliverylatitude: lat, deliverylongitude: lng, resolvedAddress: text(row.deliveryaddress) } : {}) } }; }; // Structural check only — enough to know the row is worth geocoding. The full // gate is validateBulkRow, applied after coordinates exist. const structuralError = (row) => { if (!row.customer_name) return 'No receiver name'; if (!row.deliveryaddress) return 'No delivery address'; if (!row.customer_phone) return 'No phone number'; if (row.finalprice !== '' && Number.isNaN(Number(row.finalprice))) return `Price "${row.finalprice}" is not a number`; return null; }; export const mapSheetRecords = (records, headers, sheetName) => { const rows = []; const errors = []; const ignoredColumns = new Set(); records.forEach((raw, i) => { // +2: the header row is line 1, so the first data row is line 2 — the line // number an operator sees in their own spreadsheet. const line = i + 2; const { row, ignored } = shapeRow(raw, line); ignored.forEach((c) => ignoredColumns.add(c)); // A trailing blank row is an artefact of the file, not an operator error. if (!row.customer_name && !row.deliveryaddress && !row.customer_phone) return; const error = structuralError(row); if (error) errors.push({ line, text: row.customer_name || row.deliveryaddress || `Row ${line}`, reason: error }); else rows.push(row); }); const normalised = headers.map(normalizeHeader); const missingRequired = requiredSheetColumns().filter((c) => !normalised.includes(normalizeHeader(c))); return { rows, errors, sheetName, ignoredColumns: [...ignoredColumns], // Reported, not enforced: the page only warns about these too, and a // hand-built sheet using plain headers ("name", "phone") legitimately has // none of the tenant's official titles while still being complete. missingRequired, recognisedColumns: headers.filter((h) => rowFieldForHeader(h)).map((h) => String(h).trim()), duplicates: findDuplicateRows(rows) }; }; // Same recipient at the same address twice in one file. Reported, never removed // automatically — two parcels to one door is a legitimate order, and deciding // which is which is the operator's call, not the parser's. export const findDuplicateRows = (rows) => { const seen = new Map(); const dupes = []; rows.forEach((r) => { const key = `${r.customer_phone}|${normalizeHeader(r.deliveryaddress)}`; if (seen.has(key)) dupes.push({ line: r.line, firstLine: seen.get(key), customer_name: r.customer_name }); else seen.set(key, r.line); }); return dupes; }; export const parseBulkFile = (file) => new Promise((resolve, reject) => { if (!file) { reject(new Error('No file selected.')); return; } const isCsv = CSV_EXT.test(file.name); const isExcel = EXCEL_EXT.test(file.name); if (!isCsv && !isExcel) { reject(new Error(`“${file.name}” isn’t a spreadsheet. Upload a .csv, .xls or .xlsx file.`)); return; } if (isCsv) { Papa.parse(file, { header: true, dynamicTyping: false, skipEmptyLines: true, complete: (results) => { if (!results.data?.length) { reject(new Error('That CSV has a header row but no data rows.')); return; } resolve(mapSheetRecords(results.data, results.meta.fields || [], file.name)); }, error: (err) => reject(new Error(`Couldn’t read that CSV — ${err.message}`)) }); return; } const reader = new FileReader(); reader.onerror = () => reject(new Error('Couldn’t read that file.')); reader.onload = (e) => { try { const workbook = XLSX.read(e.target.result, { type: 'binary' }); const sheetName = workbook.SheetNames[0]; // Only the first sheet is read, and the name is reported back so an // operator whose data sits on "Sheet2" can see which one was used. const records = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '', raw: false }); if (!records?.length) { reject(new Error(`Sheet “${sheetName}” is empty.`)); return; } resolve(mapSheetRecords(records, Object.keys(records[0]), `${file.name} · ${sheetName}`)); } catch (err) { reject(new Error(`Couldn’t read that spreadsheet — ${err.message}`)); } }; reader.readAsBinaryString(file); }); // ---- downloads -------------------------------------------------------------- // Hands the operator a file built from data they already supplied — a Blob // assembled in the page, not a fetch and not an upload. export const downloadCsv = (filename, csv) => { const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' })); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); }; const toCsv = (headers, rows) => [headers, ...rows] .map((r) => r.map((c) => (/[",\n]/.test(String(c ?? '')) ? `"${String(c).replace(/"/g, '""')}"` : String(c ?? ''))).join(',')) .join('\r\n'); // A blank sheet with the exact headers this parser reads, so operators stop // guessing at column titles. export const templateCsv = () => toCsv(TEMPLATE_HEADERS, [['Ravi Kumar', '9812345678', '12 Trichy Rd, Coimbatore', '641018', 'Coimbatore', 'Documents', '1', '']]); // The rows that did NOT go through, in the same column shape, so they can be // fixed and re-uploaded. This is what makes a partial success recoverable // without re-submitting the rows that already landed. export const failedRowsCsv = (failed) => toCsv( [...TEMPLATE_HEADERS, 'Reason'], failed.map((r) => [ r.customer_name || '', r.customer_phone || '', r.deliveryaddress || '', r.deliverypincode || '', r.deliverycity || '', r.itemdescription || '', r.quantity ?? 1, r.finalprice ?? '', r.error || r.reason || 'Rejected' ]) );