initial commit
This commit is contained in:
223
src/components/assistant/bulkFile.js
Normal file
223
src/components/assistant/bulkFile.js
Normal file
@@ -0,0 +1,223 @@
|
||||
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'
|
||||
])
|
||||
);
|
||||
Reference in New Issue
Block a user