224 lines
9.0 KiB
JavaScript
224 lines
9.0 KiB
JavaScript
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'
|
||
])
|
||
);
|