implemenation on the bot
This commit is contained in:
@@ -9,13 +9,25 @@ import { parseDoormileTimestamp } from './doormileTimestamp';
|
||||
// to import this (dispatch/CLAUDE.md is explicit that folder isn't touched
|
||||
// casually); this is purely additive.
|
||||
//
|
||||
// Half-open [startHour, endHour) ranges in LOCAL time. Gaps (8–9am,
|
||||
// 12:30pm–4pm, after 7pm) are intentional — a row in a gap belongs to no
|
||||
// batch, not the nearest one.
|
||||
// Half-open [startHour, endHour) ranges in LOCAL time, and they COVER THE
|
||||
// WHOLE DAY. That is the point.
|
||||
//
|
||||
// They used to be 0–8, 9–12.5 and 16–19 — 14.5 of 24 hours, with gaps at
|
||||
// 8–9am, 12:30–4pm and after 7pm. Those windows came from jupiter, where
|
||||
// bucketing ran on `expecteddeliverytime` and they described PROMISED DELIVERY
|
||||
// SLOTS. This app buckets on `orderdate` (when the order was placed) instead,
|
||||
// and orders are placed all day: an order created at 2:43pm fell in the
|
||||
// 12:30–4pm gap, belonged to no batch, and vanished from every batch filter on
|
||||
// both the Deliveries and Dispatch pages. 40% of the clock was a black hole.
|
||||
//
|
||||
// Each window now runs to the start of the next, so every row lands in exactly
|
||||
// one batch. Every START hour is unchanged, which makes this strictly additive:
|
||||
// no row that already had a batch moves to a different one — only rows that had
|
||||
// none gain one.
|
||||
export const BATCHES = [
|
||||
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 8 },
|
||||
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
||||
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 19 }
|
||||
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 9 },
|
||||
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 16 },
|
||||
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 24 }
|
||||
];
|
||||
|
||||
export const getBatchForHour = (h) => {
|
||||
@@ -25,6 +37,18 @@ export const getBatchForHour = (h) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Human-readable window, e.g. "12 AM–9 AM" / "After 4 PM". Kept here so the
|
||||
// pages' pickers describe the same ranges they filter by.
|
||||
const clock = (h) => {
|
||||
const whole = Math.floor(h);
|
||||
const mins = Math.round((h - whole) * 60);
|
||||
const suffix = whole >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = whole % 12 === 0 ? 12 : whole % 12;
|
||||
return mins ? `${hour12}:${String(mins).padStart(2, '0')} ${suffix}` : `${hour12} ${suffix}`;
|
||||
};
|
||||
|
||||
export const batchRangeLabel = (b) => (b.endHour >= 24 ? `After ${clock(b.startHour)}` : `${clock(b.startHour)}–${clock(b.endHour)}`);
|
||||
|
||||
// The one field batch bucketing uses across the app: a booking's `orderdate`
|
||||
// (== createdat — see fetchDeliveries in api.js). `assigntime` and
|
||||
// `expecteddeliverytime` were both tried and rejected — see dispatch/
|
||||
|
||||
150
src/utils/bulkOrderColumns.js
Normal file
150
src/utils/bulkOrderColumns.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// ==============================|| Bulk-order sheet columns ||============================== //
|
||||
//
|
||||
// The single definition of what a bulk-order spreadsheet may look like. Two
|
||||
// consumers share it:
|
||||
//
|
||||
// • pages/nearle/orders/multipleOrders.js — the console's bulk-upload page,
|
||||
// where this map originated.
|
||||
// • pages/nearle/assistant/bulkFile.js — Doormile AI's file upload.
|
||||
//
|
||||
// It lives here so a sheet that uploads on the page also uploads in the bot,
|
||||
// permanently. Copying it into the assistant was the alternative and was
|
||||
// rejected for the obvious reason: five pages once carried private STATUS_META
|
||||
// copies that drifted into disagreeing about the same order's label.
|
||||
//
|
||||
// SHEET_HEADER_MAP is reproduced from the page byte for byte, including the `*`
|
||||
// suffix convention (a starred header is required) and the note about which
|
||||
// mappings were never confirmed against a live tenant sheet. Do not "tidy" the
|
||||
// keys — they are the tenant's actual column titles, spaces and all.
|
||||
|
||||
export const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
// A receiver name arrives prefixed with the sheet's own row numbering
|
||||
// ("12. Ravi Kumar") often enough that stripping it is part of parsing.
|
||||
export const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
|
||||
|
||||
export const SHEET_HEADER_MAP = {
|
||||
'pickupdate(yyyy-mmm-dd)': 'date',
|
||||
'sendername*': 'locationname',
|
||||
'senderphone*': 'locationcontact',
|
||||
'senderaddress*': 'locationaddress',
|
||||
'receivername*': 'firstname',
|
||||
receiverphone: 'contactno',
|
||||
'receiveralternatephone*': 'altcontactno',
|
||||
receiverfulladdress: 'address',
|
||||
// Best-effort — no live sample sheet to confirm these are the tenant's
|
||||
// actual column headers. Without them deliverycity/deliverypincode in
|
||||
// the submit payload were always sent blank; if these names are wrong,
|
||||
// behavior is unchanged from before (still blank), not worse.
|
||||
receivercity: 'city',
|
||||
receiverpincode: 'postcode',
|
||||
receiverlatitude: 'latitude',
|
||||
receiverlongitude: 'longitude',
|
||||
'itemdescription*': 'description',
|
||||
Quantity: 'quantity',
|
||||
' Collect Cash': 'collectionamt'
|
||||
};
|
||||
|
||||
// Starred headers are the ones the page reports as missing.
|
||||
export const requiredSheetColumns = () => Object.keys(SHEET_HEADER_MAP).filter((k) => k.trim().endsWith('*'));
|
||||
|
||||
// ---- assistant-side translation ---------------------------------------------
|
||||
//
|
||||
// The page consumes the internal names above (`firstname`, `contactno`, …)
|
||||
// because its row editors were built on them. The assistant's rows are shaped
|
||||
// like a booking payload instead, so the two are bridged here rather than in
|
||||
// either consumer.
|
||||
const INTERNAL_TO_ROW = {
|
||||
firstname: 'customer_name',
|
||||
contactno: 'customer_phone',
|
||||
address: 'deliveryaddress',
|
||||
postcode: 'deliverypincode',
|
||||
city: 'deliverycity',
|
||||
latitude: 'deliverylatitude',
|
||||
longitude: 'deliverylongitude',
|
||||
description: 'itemdescription',
|
||||
quantity: 'quantity'
|
||||
};
|
||||
|
||||
// Headers someone typing their own sheet actually writes. Accepted in addition
|
||||
// to the tenant's official titles, never instead of them.
|
||||
//
|
||||
// `collectionamt` (the sheet's "Collect Cash") is deliberately NOT treated as a
|
||||
// price: it is cash to collect from the recipient, while `finalprice` is what
|
||||
// the delivery costs. Mapping one onto the other would quietly bill the wrong
|
||||
// number on every row.
|
||||
const PLAIN_ALIASES = {
|
||||
customer_name: ['name', 'customername', 'customer', 'recipient', 'recipientname', 'receiver', 'to'],
|
||||
customer_phone: ['phone', 'mobile', 'phonenumber', 'mobilenumber', 'customerphone', 'contact', 'recipientphone'],
|
||||
deliveryaddress: ['address', 'deliveryaddress', 'dropaddress', 'fulladdress', 'destination'],
|
||||
deliverypincode: ['pincode', 'pin', 'postcode', 'zip', 'zipcode', 'deliverypincode'],
|
||||
deliverycity: ['city', 'town', 'deliverycity'],
|
||||
deliverylatitude: ['latitude', 'lat', 'deliverylatitude'],
|
||||
deliverylongitude: ['longitude', 'lng', 'lon', 'deliverylongitude'],
|
||||
itemdescription: ['description', 'item', 'itemdescription', 'contents', 'goods', 'particulars'],
|
||||
itemcategory: ['category', 'itemcategory', 'type'],
|
||||
quantity: ['quantity', 'qty', 'parcels', 'pieces', 'count'],
|
||||
finalprice: ['price', 'amount', 'charge', 'deliverycharge', 'finalprice', 'rate', 'fare'],
|
||||
weight: ['weight', 'kg', 'kgs']
|
||||
};
|
||||
|
||||
// normalised header → assistant row field. Built once; the tenant's official
|
||||
// titles win over a plain alias if a sheet somehow carries both.
|
||||
const ROW_FIELD_BY_HEADER = (() => {
|
||||
const out = {};
|
||||
Object.entries(PLAIN_ALIASES).forEach(([field, headers]) => {
|
||||
headers.forEach((h) => {
|
||||
out[normalizeHeader(h)] = field;
|
||||
});
|
||||
});
|
||||
Object.entries(SHEET_HEADER_MAP).forEach(([sheetHeader, internal]) => {
|
||||
const field = INTERNAL_TO_ROW[internal];
|
||||
if (!field) return;
|
||||
out[normalizeHeader(sheetHeader)] = field;
|
||||
out[normalizeHeader(sheetHeader).replace(/\*+$/, '')] = field;
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
// The `*` in a sheet title is a "this one is required" annotation the tenant
|
||||
// types, not part of the column's name — so `Receiver Phone*` and
|
||||
// `ReceiverPhone` are the same column and both must resolve. Only the
|
||||
// assistant's lookup is star-tolerant; normalizeHeader itself is untouched,
|
||||
// because multipleOrders.js derives its missing-required warning from the star
|
||||
// and loosening that would change the page's behaviour.
|
||||
const unstar = (h) => normalizeHeader(h).replace(/\*+$/, '');
|
||||
|
||||
export const rowFieldForHeader = (header) => ROW_FIELD_BY_HEADER[normalizeHeader(header)] || ROW_FIELD_BY_HEADER[unstar(header)] || null;
|
||||
|
||||
// Every field the assistant can fill from a sheet, for the downloadable template.
|
||||
export const TEMPLATE_HEADERS = [
|
||||
'Receiver Name*',
|
||||
'Receiver Phone*',
|
||||
'Receiver Full Address*',
|
||||
'Receiver Pincode*',
|
||||
'Receiver City',
|
||||
'Item Description*',
|
||||
'Quantity',
|
||||
'Price'
|
||||
];
|
||||
|
||||
// ---- one sheet row → one assistant row --------------------------------------
|
||||
//
|
||||
// Unrecognised columns are RETURNED, not dropped silently: an operator whose
|
||||
// price column is titled something unexpected needs to be told it was ignored,
|
||||
// or they'll submit 200 orders priced from a column that was never read.
|
||||
export const mapSheetRow = (raw) => {
|
||||
const row = {};
|
||||
const ignored = [];
|
||||
|
||||
Object.entries(raw || {}).forEach(([header, value]) => {
|
||||
const field = rowFieldForHeader(header);
|
||||
if (!field) {
|
||||
if (String(header || '').trim() && String(value ?? '').trim()) ignored.push(String(header).trim());
|
||||
return;
|
||||
}
|
||||
row[field] = field === 'customer_name' ? cleanReceiverName(value) : value;
|
||||
});
|
||||
|
||||
return { row, ignored };
|
||||
};
|
||||
@@ -31,7 +31,21 @@ doormileAxios.interceptors.response.use(
|
||||
localStorage.clear();
|
||||
window.location.replace('/login');
|
||||
}
|
||||
return Promise.reject(error.response?.data || error);
|
||||
// Reject with the server's own body (callers read `err.message`), but keep
|
||||
// the HTTP status reachable as `err.httpStatus`.
|
||||
//
|
||||
// Without this the status is lost entirely: every caller's `err.response`
|
||||
// is undefined, so a 404 and a 500 and a validation failure all collapse
|
||||
// into the same generic "something went wrong". The assistant's write
|
||||
// paths need to tell "that route doesn't exist" apart from "your body was
|
||||
// wrong", and that distinction only exists in the status code.
|
||||
//
|
||||
// Non-enumerable so nothing that spreads or JSON-stringifies the body
|
||||
// starts carrying it around.
|
||||
const body = error.response?.data;
|
||||
if (body === undefined || body === null) return Promise.reject(error);
|
||||
const payload = typeof body === 'object' ? body : { message: String(body) };
|
||||
return Promise.reject(Object.defineProperty(payload, 'httpStatus', { value: error.response?.status, enumerable: false }));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user