300 lines
13 KiB
JavaScript
300 lines
13 KiB
JavaScript
import { createExpressBookingBulk, getAdminPricing } from 'pages/api/doormileApi';
|
|
import { calculateDrivingDistance, calculateTotalCharge } from 'utils/distance';
|
|
import { buildOrderPayload } from './orderActions';
|
|
|
|
// ==============================|| Doormile AI — bulk order creation ||============================== //
|
|
//
|
|
// Third write capability, and the highest-blast-radius one: a single press can
|
|
// dispatch dozens of riders. Everything here is built around making that
|
|
// visible BEFORE it happens and legible AFTER.
|
|
//
|
|
// Shared with the single-order path on purpose:
|
|
// • buildOrderPayload — so a bulk row and a single order are byte-identical
|
|
// on the wire, including the pickuplocationid workaround (that field 500s
|
|
// server-side; raw pickup fields are sent instead).
|
|
// • validateOrderDraft — the same gate, applied per row.
|
|
|
|
// Server cap, documented in express-console-api.md. Exceeding it is a hard
|
|
// error rather than a silent truncation, so rows are chunked instead.
|
|
export const BULK_MAX = 200;
|
|
|
|
export const CREATE_BULK_TRIGGER =
|
|
/\b(?:create|add|place|book|new|bulk|multiple)\s+(?:multiple|many|several|bulk|\d+)\s*(?:orders|bookings|deliveries)\b|\bbulk\s+(?:order|booking|upload)\b|\bmultiple\s+orders\b/i;
|
|
|
|
// ---- Parsing a pasted list --------------------------------------------------
|
|
//
|
|
// Operators paste from a spreadsheet, so accept the shapes that actually
|
|
// arrive: comma, tab or pipe separated, one row per line, with an optional
|
|
// header row.
|
|
//
|
|
// name, phone, address, pincode, city, price, description
|
|
//
|
|
// Anything unparseable becomes a REPORTED row error rather than a silently
|
|
// dropped line — a bulk import that quietly skips row 14 is worse than one
|
|
// that refuses.
|
|
const SPLIT = /\t|\||,(?![^(]*\))/;
|
|
|
|
const HEADER_HINT = /name|phone|mobile|address|pincode|city|price|amount|item|description/i;
|
|
|
|
export const parseBulkRows = (text) => {
|
|
const lines = String(text || '')
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
|
|
if (!lines.length) return { rows: [], errors: [] };
|
|
|
|
// Drop a header row only when it looks like one AND carries no phone number.
|
|
const first = lines[0];
|
|
const looksLikeHeader = HEADER_HINT.test(first) && !/\d{10}/.test(first);
|
|
const body = looksLikeHeader ? lines.slice(1) : lines;
|
|
|
|
const rows = [];
|
|
const errors = [];
|
|
|
|
body.forEach((line, i) => {
|
|
const parts = line.split(SPLIT).map((p) => p.trim());
|
|
const lineNo = (looksLikeHeader ? 2 : 1) + i;
|
|
if (parts.length < 4) {
|
|
errors.push({ line: lineNo, text: line, reason: 'Needs at least name, phone, address and pincode' });
|
|
return;
|
|
}
|
|
const [customer_name, customer_phone, deliveryaddress, deliverypincode, deliverycity, finalprice, itemdescription] = parts;
|
|
rows.push({
|
|
line: lineNo,
|
|
customer_name,
|
|
customer_phone: String(customer_phone || '').replace(/\D/g, ''),
|
|
deliveryaddress,
|
|
deliverypincode: String(deliverypincode || '').replace(/\D/g, ''),
|
|
deliverycity: deliverycity || '',
|
|
finalprice: finalprice || '',
|
|
itemdescription: itemdescription || 'Order',
|
|
itemcategory: 'General',
|
|
quantity: 1
|
|
});
|
|
});
|
|
|
|
return { rows, errors };
|
|
};
|
|
|
|
// Per-row validation. Deliberately NOT validateOrderDraft: a pasted row has no
|
|
// coordinates (there is no address search on a paste), and the single-order
|
|
// gate requires them. Bulk rows are geocoded by the caller before submit, and
|
|
// rows that fail to geocode are reported, not sent.
|
|
const PHONE_RE = /^\d{10}$/;
|
|
|
|
export const validateBulkRow = (row) => {
|
|
if (!row.customer_name) return 'Missing customer name';
|
|
if (!PHONE_RE.test(row.customer_phone)) return 'Phone must be exactly 10 digits';
|
|
if (!row.deliveryaddress) return 'Missing delivery address';
|
|
if (!row.deliverypincode) return 'Missing delivery pincode';
|
|
// Coordinates are checked BEFORE the price, because an unlocatable address is
|
|
// the root cause and a blank price is its symptom — the row was never priced
|
|
// precisely because there was nothing to route. Reporting "price must be a
|
|
// number" here sent the operator to fix the wrong column.
|
|
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
|
|
return 'Address could not be located — the order could not be routed';
|
|
}
|
|
if (row.finalprice === '' || Number.isNaN(Number(row.finalprice))) return 'Price must be a number';
|
|
return null;
|
|
};
|
|
|
|
// ---- per-row pricing --------------------------------------------------------
|
|
//
|
|
// A blank price column means "quote it", exactly as the single-order flow does —
|
|
// not zero. The tenant's pricing row is fetched ONCE for the whole file (fetching
|
|
// it per row would be 200 identical requests), then each unpriced row costs one
|
|
// OSRM call for its routed distance.
|
|
//
|
|
// A row that can't be priced keeps its blank price and carries the reason. It
|
|
// then fails validateBulkRow and is reported, rather than being submitted at a
|
|
// number nobody chose.
|
|
export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, shouldStop } = {}) => {
|
|
const pricing = (await getAdminPricing()) || [];
|
|
// tenantid is numeric on the pricing row and a string from localStorage — a
|
|
// strict comparison here silently priced every order at zero once before.
|
|
const match = pricing.find((p) => String(p.tenantid) === String(tenantid));
|
|
|
|
const out = [];
|
|
for (let i = 0; i < rows.length; i += 1) {
|
|
const row = rows[i];
|
|
// A stop leaves the remaining rows exactly as they were — unpriced and
|
|
// therefore invalid — instead of half-pricing the file.
|
|
if (shouldStop?.()) {
|
|
out.push(...rows.slice(i));
|
|
break;
|
|
}
|
|
onProgress?.({ done: i, total: rows.length, current: row.customer_name || row.deliveryaddress });
|
|
|
|
if (String(row.finalprice ?? '') !== '') {
|
|
out.push(row);
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
if (!match) {
|
|
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const km = await calculateDrivingDistance(
|
|
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
|
|
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
|
|
).catch(() => null);
|
|
|
|
if (km == null) {
|
|
out.push({ ...row, priceError: 'could not measure the distance' });
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
|
|
out.push({ ...row, finalprice: Number(Number(total).toFixed(2)), km, quoted: true });
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// ---- double-submit protection ----------------------------------------------
|
|
//
|
|
// POST /admin/expressbooking/bulk takes no idempotency key, so if a submit times
|
|
// out the operator cannot tell what landed — and re-sending the file double-books
|
|
// every row that succeeded. Fingerprints of what has already been submitted this
|
|
// session are kept so an identical re-submit can at least be questioned.
|
|
//
|
|
// Session-scoped on purpose: it guards the realistic accident (pressing Create
|
|
// twice, or re-uploading the same file minutes later), not a next-day re-run,
|
|
// which may be a legitimately repeated delivery round.
|
|
const submitted = new Set();
|
|
|
|
export const rowSetFingerprint = (rows) =>
|
|
(rows || [])
|
|
.map((r) => `${r.customer_phone}|${r.deliverypincode}|${r.finalprice}`)
|
|
.sort()
|
|
.join(';');
|
|
|
|
export const wasAlreadySubmitted = (rows) => rows?.length > 0 && submitted.has(rowSetFingerprint(rows));
|
|
|
|
const chunk = (arr, size) => {
|
|
const out = [];
|
|
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
return out;
|
|
};
|
|
|
|
// The only bulk-writing function in the assistant.
|
|
//
|
|
// Returns a per-row outcome, never a bare success/failure. A partial success
|
|
// is the normal case for a bulk import, and the operator has to be able to see
|
|
// exactly which rows landed — otherwise the only safe response to any error is
|
|
// to assume nothing worked and re-submit everything, which double-books.
|
|
export const executeCreateBulk = async (rows, shared) => {
|
|
const started = Date.now();
|
|
// Recorded BEFORE the request, not after: a timed-out submit is the case that
|
|
// most needs the warning, and it never reaches a success handler.
|
|
submitted.add(rowSetFingerprint(rows));
|
|
// A row may carry its OWN pickup. A bulk FILE shares one kitchen, but a
|
|
// repeated day's run can span several tenants and locations — collapsing
|
|
// those onto one shared pickup would silently re-address half the orders.
|
|
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, r.__pickup ?? shared.__pickup));
|
|
const batches = chunk(payloads, BULK_MAX);
|
|
|
|
const sourceCalls = [];
|
|
let created = 0;
|
|
const failures = [];
|
|
// The ids of what actually landed, so the run can be handed straight to
|
|
// batch-assign without a re-scan of /admin/bookings to find them again.
|
|
const createdIds = [];
|
|
// {index, bookingid} — the same ids, but each still tied to its source row.
|
|
const createdPairs = [];
|
|
|
|
for (let b = 0; b < batches.length; b += 1) {
|
|
const batch = batches[b];
|
|
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
|
|
try {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const res = await createExpressBookingBulk(batch);
|
|
// Three shapes, most-nested first. The live endpoint returns
|
|
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
|
|
// — confirmed against api.doormile.com — and only the two flatter shapes
|
|
// were checked here. So `res.data` was an object rather than an array,
|
|
// `res.results` was undefined, perRow fell through to null, and the
|
|
// whole run was treated as all-or-nothing: the count came out right by
|
|
// accident (created += batch.length) while EVERY booking id was thrown
|
|
// away. That is why "Assign the 13 you just created" never appeared —
|
|
// there were no ids to offer.
|
|
const perRow = Array.isArray(res?.data?.results)
|
|
? res.data.results
|
|
: Array.isArray(res?.data)
|
|
? res.data
|
|
: Array.isArray(res?.results)
|
|
? res.results
|
|
: null;
|
|
|
|
if (res?.success === false) {
|
|
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' })));
|
|
sourceCalls.push({
|
|
name: 'createExpressBookingBulk',
|
|
target: `POST /admin/expressbooking/bulk${label}`,
|
|
status: 'error',
|
|
errorMessage: res.message || 'Rejected'
|
|
});
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
|
|
// The endpoint is documented as returning per-row results. If it does,
|
|
// trust it row by row; if it doesn't, treat the batch as all-or-nothing
|
|
// rather than inventing a success count.
|
|
if (perRow) {
|
|
perRow.forEach((r, i) => {
|
|
const index = b * BULK_MAX + i;
|
|
if (r?.success === false || r?.error) failures.push({ index, reason: r.message || r.error || 'Rejected' });
|
|
else {
|
|
created += 1;
|
|
// Paired with the row it came from, not just collected. A bare list
|
|
// of ids cannot say WHICH row produced which booking, and the
|
|
// repeat flow needs exactly that to hand each new order back to the
|
|
// rider who ran it last time.
|
|
if (r?.bookingid) {
|
|
createdIds.push(r.bookingid);
|
|
createdPairs.push({ index, bookingid: r.bookingid });
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
created += batch.length;
|
|
}
|
|
|
|
sourceCalls.push({
|
|
name: 'createExpressBookingBulk',
|
|
target: `POST /admin/expressbooking/bulk${label}`,
|
|
status: 'complete',
|
|
stats: `${batch.length} submitted`
|
|
});
|
|
} catch (err) {
|
|
// doormileAxios rejects with the response BODY; the status is attached
|
|
// as `err.httpStatus`.
|
|
const status = err.httpStatus;
|
|
const reason = err.message || 'Request failed';
|
|
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason })));
|
|
sourceCalls.push({
|
|
name: 'createExpressBookingBulk',
|
|
target: `POST /admin/expressbooking/bulk${label}`,
|
|
status: 'error',
|
|
errorMessage: `${status || 'network'} · ${reason}`
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: created > 0,
|
|
created,
|
|
// Empty when the endpoint returned no per-row array — the caller must treat
|
|
// "no ids" as "cannot offer assignment", not as "nothing was created".
|
|
createdIds,
|
|
createdPairs,
|
|
failed: failures.length,
|
|
failures,
|
|
batches: batches.length,
|
|
sourceCalls: sourceCalls.map((c) => ({ ...c, duration: `${Date.now() - started}ms` }))
|
|
};
|
|
};
|