Files
doormilxpress_astryx/src/components/assistant/bulkFile.js
2026-08-26 15:01:15 +05:30

224 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
])
);