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` })) }; };