initial commit

This commit is contained in:
2026-08-26 15:01:15 +05:30
parent aa42d9ee81
commit cf4f4ecd9c
87 changed files with 12814 additions and 2328 deletions

View File

@@ -0,0 +1,299 @@
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` }))
};
};