updates on the ui changes and changed into doormile
This commit is contained in:
203
src/lib/assistant/actions.js
Normal file
203
src/lib/assistant/actions.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createTenantCustomer } from '@/api/doormile/endpoints';
|
||||
|
||||
// ==============================|| Doormile AI — write actions ||============================== //
|
||||
//
|
||||
// The FIRST write capability in the assistant. Read the rules before adding
|
||||
// another one.
|
||||
//
|
||||
// The contract (assistant/CLAUDE.md §4): an intent never mutates. It returns a
|
||||
// PROPOSAL — the exact payload it would submit — and nothing reaches the API
|
||||
// until the operator presses Create in the panel. Parsing, validation and
|
||||
// execution are separated here so the proposal can be built, shown and
|
||||
// discarded without any possibility of a request going out.
|
||||
//
|
||||
// Customer creation was chosen as the first write deliberately: it needs two
|
||||
// required fields where an order needs fourteen, it has no CityGate pincode
|
||||
// gate, no geocoding and no delivery slot, and a wrong record is an edit
|
||||
// rather than a rider dispatched to the wrong address.
|
||||
|
||||
// "create a customer", "add new client" — an explicit verb + noun. Deliberately
|
||||
// narrow: nothing here should fire on a question that merely mentions customers.
|
||||
export const CREATE_CUSTOMER_TRIGGER = /\b(?:create|add|register|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:customer|client)\b/i;
|
||||
|
||||
// Words that are part of the instruction rather than the person's name.
|
||||
const FILLER =
|
||||
/\b(?:create|add|register|new|a|an|the|customer|client|named|called|with|phone|number|mobile|contact|no|email|id|please)\b/gi;
|
||||
|
||||
// Pulls what it can out of free text. Anything it can't find stays undefined
|
||||
// and is asked for — never guessed.
|
||||
export const parseCustomerDraft = (text) => {
|
||||
const raw = String(text || '');
|
||||
|
||||
const email = (raw.match(/[\w.+-]+@[\w-]+\.[\w.]{2,}/) || [])[0];
|
||||
|
||||
// Exactly ten digits, standalone. A longer run is not a phone number and
|
||||
// must not be silently truncated into one.
|
||||
const phone = (raw.match(/(?<!\d)(\d{10})(?!\d)/) || [])[1];
|
||||
|
||||
// Whatever is left after removing the instruction, the phone and the email
|
||||
// is the person's name.
|
||||
let nameArea = raw;
|
||||
if (email) nameArea = nameArea.replace(email, ' ');
|
||||
if (phone) nameArea = nameArea.replace(phone, ' ');
|
||||
const nameWords = nameArea
|
||||
.replace(FILLER, ' ')
|
||||
.replace(/[^A-Za-z .'-]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 1);
|
||||
|
||||
return {
|
||||
firstname: nameWords[0],
|
||||
lastname: nameWords.slice(1).join(' ') || undefined,
|
||||
phone,
|
||||
email
|
||||
};
|
||||
};
|
||||
|
||||
// Mirrors createCustomer.js's own checks — a name, and a phone of exactly ten
|
||||
// digits. If the form would refuse it, the assistant refuses it too, rather
|
||||
// than letting the server decide.
|
||||
//
|
||||
// No tenant check: a customer record carries no tenantid, and the documented
|
||||
// POST body doesn't take one. The page's own `isStaffLogin && !tid` guard
|
||||
// exists because its dropdown sends a speculative tenantid; the assistant
|
||||
// doesn't send one at all.
|
||||
export const validateCustomerDraft = (draft) => {
|
||||
const missing = [];
|
||||
if (!draft.firstname) missing.push('the customer’s name');
|
||||
if (!draft.phone || !/^\d{10}$/.test(String(draft.phone))) missing.push('a 10-digit mobile number');
|
||||
return { ok: missing.length === 0, missing };
|
||||
};
|
||||
|
||||
// The exact body that will be POSTed.
|
||||
//
|
||||
// Documented body for POST /admin/tenantcustomers is
|
||||
// { firstname, lastname, phone, email } — and a customer record carries NO
|
||||
// tenantid, which is why the tenant field was removed. A real
|
||||
// GET /admin/customers response does carry address, doorno, landmark, suburb,
|
||||
// city, state, postcode, latitude and longitude, so those are sent on a
|
||||
// best-effort basis: empty strings are dropped rather than sent as noise, and
|
||||
// if the server ignores the rest nothing breaks.
|
||||
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
||||
|
||||
export const buildCustomerPayload = (draft) =>
|
||||
clean({
|
||||
firstname: draft.firstname,
|
||||
lastname: draft.lastname || '',
|
||||
phone: draft.phone,
|
||||
email: draft.email || '',
|
||||
address: draft.address,
|
||||
doorno: draft.doorno,
|
||||
landmark: draft.landmark,
|
||||
suburb: draft.suburb,
|
||||
city: draft.city,
|
||||
state: draft.state,
|
||||
postcode: draft.postcode,
|
||||
latitude: draft.latitude,
|
||||
longitude: draft.longitude
|
||||
});
|
||||
|
||||
// The ONLY function in the assistant that mutates anything. Called exclusively
|
||||
// from the panel's confirm handler — never from an intent's run().
|
||||
//
|
||||
// ---- Which endpoint, and why -----------------------------------------------
|
||||
//
|
||||
// Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
|
||||
// by reading the docs:
|
||||
//
|
||||
// POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
//
|
||||
// 405 is the unambiguous answer: the route exists, and POST is not among its
|
||||
// methods. express-console-api.md lists /admin/customers as GET + PATCH only,
|
||||
// and the server agrees. It was pointed there briefly on explicit instruction;
|
||||
// the live 405 settled it.
|
||||
//
|
||||
// The consequence, which the assistant states in its success message rather
|
||||
// than leaving the operator to discover: a customer created here does NOT
|
||||
// appear on the Customers page, because that page reads GET /admin/customers.
|
||||
// On that resource a customer comes into existence as a side effect of a
|
||||
// booking — POST /admin/expressbooking documents `customer_phone` as "creates a
|
||||
// Guest customer if unknown". A B2C customer is, by design, someone who has
|
||||
// ordered.
|
||||
//
|
||||
// To make created customers visible on that page, one of these has to happen:
|
||||
// • the Customers page reads /admin/tenantcustomers (tried once, reverted —
|
||||
// it changes what that page means, and its edit dialog would then PATCH a
|
||||
// different store by id), or
|
||||
// • the backend adds POST /admin/customers.
|
||||
//
|
||||
// The payload keeps the address fields. The documented body is
|
||||
// { firstname, lastname, phone, email }; the rest are sent best-effort and
|
||||
// ignored if unsupported.
|
||||
export const executeCreateCustomer = async (payload) => {
|
||||
const started = Date.now();
|
||||
const call = {
|
||||
name: 'createTenantCustomer',
|
||||
target: 'POST /admin/tenantcustomers',
|
||||
stats: Object.keys(payload).join(', ')
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await createTenantCustomer(payload);
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
|
||||
// doormileApi mutations return the full envelope, so a `success: false`
|
||||
// arrives as a resolved promise, not a rejection.
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server rejected the customer.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message || 'Rejected' }]
|
||||
};
|
||||
}
|
||||
|
||||
const created = res?.data || res;
|
||||
return {
|
||||
ok: true,
|
||||
id: created?.appcustomerid ?? created?.customerid ?? created?.id,
|
||||
created,
|
||||
message: 'Customer created.',
|
||||
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created id ${created?.appcustomerid ?? created?.id ?? '—'}` }]
|
||||
};
|
||||
} catch (err) {
|
||||
// An HTTP failure used to throw straight past this function, and the panel
|
||||
// printed a generic "could not be created" with the status thrown away —
|
||||
// which is the one detail needed to tell "the route does not exist" apart
|
||||
// from "the body was wrong". Report the status, the server's own message,
|
||||
// and name the endpoint.
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
// doormileAxios rejects with the response BODY, not the axios error, so
|
||||
// `err.response` is undefined here — the status arrives as `err.httpStatus`.
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
|
||||
let message;
|
||||
if (status === 405) {
|
||||
// 405 is "the route exists but not this method" — a different fact from
|
||||
// 404, and worth stating precisely so nobody re-tries the same call.
|
||||
message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
|
||||
} else if (status === 404) {
|
||||
message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
|
||||
} else if (status === 400 || status === 422) {
|
||||
message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else if (status) {
|
||||
message = `POST /admin/tenantcustomers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else {
|
||||
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
message,
|
||||
sourceCalls: [
|
||||
{
|
||||
...call,
|
||||
duration,
|
||||
status: 'error',
|
||||
errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
268
src/lib/assistant/assignActions.js
Normal file
268
src/lib/assistant/assignActions.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { getMilers, assignMilerToBooking } from '@/api/doormile/endpoints';
|
||||
import { notifyMiler } from '@/api/doormile/endpoints';
|
||||
|
||||
const normMilerName = (n) => (n || '').toString().trim().toLowerCase();
|
||||
|
||||
export const buildMilerLookup = (milers) => {
|
||||
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
|
||||
const byProfileId = new Map((milers || []).map((m) => [String(m.milerprofileid), m]));
|
||||
const byName = new Map();
|
||||
(milers || []).forEach((m) => {
|
||||
[m.displayname, m.authname].forEach((n) => {
|
||||
const key = normMilerName(n);
|
||||
if (key && !byName.has(key)) byName.set(key, m);
|
||||
});
|
||||
});
|
||||
return { byUserId, byProfileId, byName };
|
||||
};
|
||||
|
||||
|
||||
// ==============================|| Doormile AI — assigning a rider ||============================== //
|
||||
//
|
||||
// Fourth write capability. Two endpoints, and which one is correct depends
|
||||
// entirely on how many orders are being assigned:
|
||||
//
|
||||
// ONE order → POST /admin/bookings/:id/assign-miler
|
||||
// MANY orders → POST /hub/bookings/batch-assign
|
||||
//
|
||||
// These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only
|
||||
// place stops get ordered" — after assigning it sends each affected rider's
|
||||
// whole active set to the route optimiser and writes step, per-leg distance and
|
||||
// ETA back onto `bookingassignments`. Assigning ten orders with ten single
|
||||
// calls leaves every route unsequenced and riders choosing their own order.
|
||||
//
|
||||
// ---- The two rider IDs -----------------------------------------------------
|
||||
//
|
||||
// `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify`
|
||||
// keys off a **milerprofileid**. Different identity spaces on adjacent
|
||||
// endpoints — doormile-flow.md calls this out by name. Getting it wrong fails
|
||||
// quietly in both directions: the assign 404s, or the rider is never told.
|
||||
// `buildMilerLookup` is the bridge, and it is the SAME one orders.js already
|
||||
// uses for exactly this translation. Don't grow a second lookup here.
|
||||
//
|
||||
// ---- Assignment is normally automatic --------------------------------------
|
||||
//
|
||||
// Creating a booking publishes `booking.assignment_requested`; a worker finds a
|
||||
// rider within 10km via Redis GEO, scores them, and commits — retrying 5 times,
|
||||
// 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a
|
||||
// decision the backend is already making, which is why the flow re-reads the
|
||||
// booking's current assignee before offering to change it.
|
||||
|
||||
export const ASSIGN_TRIGGER =
|
||||
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
|
||||
|
||||
// Riders, plus the id bridge, in one call.
|
||||
export const loadRiders = async () => {
|
||||
const milers = (await getMilers()) || [];
|
||||
return { milers, lookup: buildMilerLookup(milers) };
|
||||
};
|
||||
|
||||
const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`;
|
||||
|
||||
// Available riders first — an operator picking by hand wants the ones who can
|
||||
// actually take it at the top. Beyond that, alphabetical: any other ordering
|
||||
// (nearest, least loaded) would need position data this list doesn't carry, and
|
||||
// a proximity label nobody can stand behind is worse than none.
|
||||
const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || ''));
|
||||
|
||||
export const riderOptions = (milers) =>
|
||||
[...(milers || [])]
|
||||
.sort((a, b) => {
|
||||
const byAvail = Number(isAvailable(b)) - Number(isAvailable(a));
|
||||
return byAvail || riderName(a).localeCompare(riderName(b));
|
||||
})
|
||||
.map((m) => ({
|
||||
value: String(m.userid),
|
||||
label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '),
|
||||
record: m
|
||||
}));
|
||||
|
||||
// Who currently holds this booking, resolved to a person rather than an id.
|
||||
export const currentAssignee = (booking, lookup) =>
|
||||
booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null;
|
||||
|
||||
export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null);
|
||||
|
||||
// ---- one order --------------------------------------------------------------
|
||||
export const executeAssign = async (booking, rider) => {
|
||||
const started = Date.now();
|
||||
const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`;
|
||||
const call = {
|
||||
name: 'assignMilerToBooking',
|
||||
target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`,
|
||||
stats: `${bookingLabel} → ${riderName(rider)}`
|
||||
};
|
||||
|
||||
try {
|
||||
// mileruserid, NOT milerprofileid. See the note at the top of this file.
|
||||
const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) });
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server refused the assignment.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
|
||||
};
|
||||
}
|
||||
|
||||
const sourceCalls = [{ ...call, duration, status: 'complete' }];
|
||||
|
||||
// CLAUDE.md §9: any mutation that affects a rider is followed by a push.
|
||||
// It is deliberately NOT allowed to fail the assignment — the order IS
|
||||
// assigned at this point, and reporting otherwise would be a lie.
|
||||
// Whether the push ACTUALLY went out, not whether it could have been
|
||||
// attempted. This used to be reported as `Boolean(rider.milerprofileid)`
|
||||
// — i.e. "this rider has an id, so assume they were told" — which is a
|
||||
// different claim entirely. Caught live: notify returned 400 and the
|
||||
// assistant still said "The rider has been notified."
|
||||
//
|
||||
// The assignment itself is unaffected; it had already landed. But a
|
||||
// dispatcher who believes a rider was pinged does not follow up, and this
|
||||
// bot's whole contract is that it never states something it has not
|
||||
// confirmed.
|
||||
let notified = false;
|
||||
|
||||
if (rider.milerprofileid) {
|
||||
try {
|
||||
await notifyMiler(rider.milerprofileid);
|
||||
notified = true;
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
||||
status: 'complete',
|
||||
stats: 'rider notified'
|
||||
});
|
||||
} catch (err) {
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
||||
status: 'error',
|
||||
errorMessage: err.message || 'notification failed'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No profile id means no push is possible — say so rather than letting
|
||||
// the operator assume the rider's phone buzzed.
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: '/admin/milers/:id/notify',
|
||||
status: 'error',
|
||||
errorMessage: 'this rider has no milerprofileid, so no notification could be sent'
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, rider, bookingLabel, notified, sourceCalls };
|
||||
} catch (err) {
|
||||
// doormileAxios rejects with the response BODY; the status rides on
|
||||
// err.httpStatus.
|
||||
const status = err.httpStatus;
|
||||
const message = status
|
||||
? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${
|
||||
err.message ? ` — ${err.message}` : ''
|
||||
}. Nothing changed.`
|
||||
: `${err.message || 'The request failed'} — nothing changed.`;
|
||||
return {
|
||||
ok: false,
|
||||
message,
|
||||
sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ---- repeat a run, keeping each order with the rider who ran it last --------
|
||||
//
|
||||
// A repeated run is the SAME drops to the SAME doors. The rider who did them
|
||||
// yesterday already knows the buzzer, the gate code and which side of the
|
||||
// building to park on, so re-deriving an assignment from scratch throws away
|
||||
// the one piece of routing knowledge the previous day produced.
|
||||
//
|
||||
// Deliberately built on /admin/bookings/:id/assign-miler, one call per order,
|
||||
// rather than the hub batch endpoint: batch-assign lets the BACKEND choose
|
||||
// riders, which is the opposite of the intent here, and it is refused to every
|
||||
// non-hub login anyway (403, confirmed live).
|
||||
//
|
||||
// The trade-off this accepts: assigning individually does not sequence a
|
||||
// rider's stops. Yesterday's run was already sequenced for these same drops,
|
||||
// so the ordering is not arbitrary — but it is not recomputed either, and the
|
||||
// caller states that rather than implying a fresh optimisation.
|
||||
export const executeRepeatAssign = async (createdPairs, rows) => {
|
||||
const started = Date.now();
|
||||
|
||||
// Only rows whose source order actually had a rider. A blank one is not a
|
||||
// failure — yesterday's copy was never assigned either.
|
||||
const targets = (createdPairs || [])
|
||||
.map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null }))
|
||||
.filter((t) => t.mileruserid != null);
|
||||
|
||||
if (!targets.length) {
|
||||
return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] };
|
||||
}
|
||||
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
const assignedRiders = new Set();
|
||||
const failures = [];
|
||||
let assigned = 0;
|
||||
|
||||
// Sequential on purpose. These are writes against real dispatch records, and
|
||||
// firing a burst of them concurrently makes a partial failure much harder to
|
||||
// report accurately — which order did not land, and to whom.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const t of targets) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
|
||||
assigned += 1;
|
||||
assignedRiders.add(String(t.mileruserid));
|
||||
} catch (err) {
|
||||
failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` });
|
||||
}
|
||||
}
|
||||
|
||||
const sourceCalls = [
|
||||
{
|
||||
name: 'assignMilerToBooking',
|
||||
target: 'POST /admin/bookings/:id/assign-miler',
|
||||
duration: `${Date.now() - started}ms`,
|
||||
status: failures.length ? 'error' : 'complete',
|
||||
stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`,
|
||||
errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined
|
||||
}
|
||||
];
|
||||
|
||||
// One push per rider, not per order — a rider getting ten of yesterday's
|
||||
// drops back should feel one buzz, not ten.
|
||||
let notified = 0;
|
||||
if (lookup) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const userid of assignedRiders) {
|
||||
const rider = lookup.byUserId.get(userid);
|
||||
if (rider?.milerprofileid) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await notifyMiler(rider.milerprofileid);
|
||||
notified += 1;
|
||||
} catch {
|
||||
// Notification failure never fails the assignment — the order IS
|
||||
// assigned by this point. It is reported, not swallowed.
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: 'POST /admin/milers/:id/notify',
|
||||
status: notified === assignedRiders.size ? 'complete' : 'error',
|
||||
stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: assigned > 0,
|
||||
assigned,
|
||||
skipped: (createdPairs || []).length - targets.length,
|
||||
failures,
|
||||
notified,
|
||||
riders: assignedRiders.size,
|
||||
sourceCalls
|
||||
};
|
||||
};
|
||||
200
src/lib/assistant/assignFlow.js
Normal file
200
src/lib/assistant/assignFlow.js
Normal file
@@ -0,0 +1,200 @@
|
||||
import { scanBookings } from './intents';
|
||||
import { ORDER_STATUS_LABELS, groupForBookingStatus } from '@/lib/orderStatusGroups';
|
||||
import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational assign ||============================== //
|
||||
//
|
||||
// Reached two ways, and the difference is only what the draft is seeded with:
|
||||
//
|
||||
// • straight after creating an order — the panel seeds `booking`, so the
|
||||
// first question is already about a known order
|
||||
// • "assign a rider to DM-BK-…" — the operator names it, and the first step
|
||||
// resolves that reference against the real booking list
|
||||
//
|
||||
// ---- Why there is a "keep or replace" step ---------------------------------
|
||||
//
|
||||
// The backend assigns riders BY ITSELF within seconds of creation and keeps
|
||||
// retrying for ten minutes (doormile-flow.md §3). By the time an operator
|
||||
// answers a dropdown, a rider may already hold the order — one the backend
|
||||
// chose on proximity, which is information this list does not have.
|
||||
//
|
||||
// So the flow re-reads the booking's current assignee and, if there is one,
|
||||
// asks before replacing them. Silently overwriting would throw away a better
|
||||
// decision and strand a rider who has already been told the job is theirs.
|
||||
|
||||
export const ASSIGN_STEPS = [
|
||||
{
|
||||
id: 'bookingno',
|
||||
// A SELECT, not a text field.
|
||||
//
|
||||
// This used to ask "Give me its number — for example DM-BK-0D915D43-33705"
|
||||
// and wait for it to be typed. Two problems with that. An operator does not
|
||||
// know the number by heart, so the question sent them to another screen to
|
||||
// go and read one. And it was the fallback reached whenever a create did
|
||||
// not hand back a booking id — so the moment the API response shape was
|
||||
// anything other than expected, "assign the order I just made" turned into
|
||||
// "recite a 20-character reference".
|
||||
//
|
||||
// A list removes the failure mode rather than patching it: unassigned
|
||||
// orders first, because those are the ones anyone is here to assign.
|
||||
//
|
||||
// `resolve` below is kept, so typing a number still works — the engine runs
|
||||
// it for a picked option too, and it already matches on bookingid.
|
||||
type: 'select',
|
||||
ask: 'Which order should I assign?',
|
||||
// The list is built from a live scan, so it can come back empty — an API
|
||||
// failure, or genuinely no bookings. `resolve` below still accepts a typed
|
||||
// number, so say that rather than leaving Cancel as the only way out.
|
||||
emptyHint: 'I couldn’t load the order list. Type the order number instead — for example DM-BK-0D915D43-33705.',
|
||||
// Seeded by the panel when this follows a create, so it is skipped there —
|
||||
// that path goes straight to the rider list.
|
||||
when: (d) => !d.booking,
|
||||
options: async () => {
|
||||
const scan = await scanBookings();
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
|
||||
return [...(scan.rows || [])]
|
||||
.map((b) => ({ b, holder: lookup ? currentAssignee(b, lookup) : null }))
|
||||
// Unassigned first; the API already returns newest-first, and that
|
||||
// order is preserved within each group by a stable sort.
|
||||
.sort((x, y) => Number(Boolean(x.holder)) - Number(Boolean(y.holder)))
|
||||
// A dropdown is for picking, not for browsing. Past this many the
|
||||
// operator is better served by naming the order.
|
||||
.slice(0, 30)
|
||||
.map(({ b, holder }) => ({
|
||||
value: String(b.bookingid),
|
||||
label: [
|
||||
b.bookingno || `#${b.bookingid}`,
|
||||
// A raw booking carries `status`; `orderstatus` is the mapped
|
||||
// field the LIST pages add. Reading the wrong one made every
|
||||
// option in this dropdown say "Unknown" — seen live.
|
||||
ORDER_STATUS_LABELS[groupForBookingStatus(b.status ?? b.orderstatus)] || 'Unknown',
|
||||
holder ? `held by ${describeRider(holder)}` : 'unassigned'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}));
|
||||
},
|
||||
resolve: async (raw) => {
|
||||
const needle = String(raw || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!needle) return { error: 'I need an order number.' };
|
||||
|
||||
const scan = await scanBookings();
|
||||
|
||||
// EXACT matches win across the whole list before any fuzzy one is
|
||||
// considered. The old version tested all three conditions per row inside
|
||||
// a single find(), so row ORDER decided the winner: a row whose
|
||||
// bookingno merely CONTAINED the needle could match before the row whose
|
||||
// bookingid actually equalled it.
|
||||
//
|
||||
// That is not theoretical. Picking from the dropdown sends a bare
|
||||
// numeric bookingid as the answer, and every bookingno ends in a digit
|
||||
// run — so "32143" substring-matched DM-BK-81DFAF19-32143 while some
|
||||
// other booking genuinely had id 32143. The assignment then went to a
|
||||
// different order than the one on screen, which reads as "it said it
|
||||
// assigned but nothing updated".
|
||||
const exact = scan.rows.find(
|
||||
(b) => String(b.bookingid) === needle || String(b.bookingno || '').toLowerCase() === needle
|
||||
);
|
||||
|
||||
// Substring is a convenience for someone typing part of a reference, so
|
||||
// it needs enough characters to identify one order. Below this it is
|
||||
// guesswork — "1" would match most of the list.
|
||||
const MIN_FUZZY = 4;
|
||||
const found =
|
||||
exact ||
|
||||
(needle.length >= MIN_FUZZY
|
||||
? scan.rows.find((b) => String(b.bookingno || '').toLowerCase().includes(needle))
|
||||
: null);
|
||||
if (!found) {
|
||||
return {
|
||||
error: scan.truncated
|
||||
? `I couldn't find ${raw} in the most recent ${scan.scanned.toLocaleString(
|
||||
'en-IN'
|
||||
)} bookings. It may be further back than I can scan.`
|
||||
: `I couldn't find an order matching ${raw}. Check the number and try again.`
|
||||
};
|
||||
}
|
||||
// Resolve the current holder HERE, not afterwards. advanceFlow evaluates
|
||||
// keepOrReplace's `when` the instant this step is applied — a lookup that
|
||||
// lands even one tick later means the step is skipped and an
|
||||
// already-assigned order is silently reassigned.
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
return { value: { booking: found, currentRider: lookup ? currentAssignee(found, lookup) : null } };
|
||||
},
|
||||
apply: (d, v) => ({ ...d, booking: v.booking, __currentRider: v.currentRider })
|
||||
},
|
||||
{
|
||||
id: 'keepOrReplace',
|
||||
type: 'select',
|
||||
// The question text is rewritten by the panel to name the current holder;
|
||||
// this is the fallback if that lookup came back empty.
|
||||
ask: 'This order already has a rider. Keep them, or assign someone else?',
|
||||
when: (d) => Boolean(d.__currentRider),
|
||||
options: async (d) => [
|
||||
{ value: 'keep', label: `Keep ${describeRider(d.__currentRider) || 'the current rider'}` },
|
||||
{ value: 'replace', label: 'Assign someone else' }
|
||||
],
|
||||
apply: (d, v) => ({ ...d, keepOrReplace: v, __keep: v === 'keep' })
|
||||
},
|
||||
{
|
||||
id: 'mileruserid',
|
||||
type: 'select',
|
||||
ask: 'Which rider should take it?',
|
||||
when: (d) => !d.__keep,
|
||||
options: async () => {
|
||||
const { milers } = await loadRiders();
|
||||
return riderOptions(milers);
|
||||
},
|
||||
// Resolved rather than taken straight from the clicked option.
|
||||
//
|
||||
// `apply` used to read `option?.record`, which is only populated when a
|
||||
// button was CLICKED. Answer this step by typing — a rider's name, or an
|
||||
// id — and option is undefined, so __rider was undefined, and executeAssign
|
||||
// then threw on `rider.userid` inside its try/catch. The operator saw a
|
||||
// failed assignment with "Cannot read properties of undefined" instead of
|
||||
// an answer.
|
||||
//
|
||||
// Resolving here means both paths produce a real miler record, and a name
|
||||
// that matches nobody gets a sentence rather than a crash.
|
||||
resolve: async (raw) => {
|
||||
const needle = String(raw || '').trim();
|
||||
if (!needle) return { error: 'I need a rider.' };
|
||||
|
||||
const { milers, lookup } = await loadRiders();
|
||||
const rider =
|
||||
lookup?.byUserId?.get(needle) ||
|
||||
lookup?.byName?.get(needle.toLowerCase()) ||
|
||||
(milers || []).find((m) => String(m.userid) === needle) ||
|
||||
(milers || []).find((m) => (m.displayname || m.authname || '').toLowerCase() === needle.toLowerCase());
|
||||
|
||||
if (!rider) return { error: `I couldn't find a rider matching ${raw}. Pick one from the list.` };
|
||||
// Both ids travel: assign-miler needs `userid`, the push needs
|
||||
// `milerprofileid`, and they are different fields on the same record.
|
||||
return { value: { mileruserid: String(rider.userid), rider } };
|
||||
},
|
||||
apply: (d, v) => ({ ...d, mileruserid: v.mileruserid, __rider: v.rider })
|
||||
}
|
||||
];
|
||||
|
||||
// `booking` is optional — present when this follows a create.
|
||||
export const startAssignFlow = async (booking) => {
|
||||
const draft = booking ? { booking } : {};
|
||||
if (booking) {
|
||||
// Resolve who holds it right now, so the keep/replace step knows whether to
|
||||
// ask at all. A failure here degrades to "nobody assigned yet", which is
|
||||
// the safe direction: the operator is asked to choose rather than being
|
||||
// told something untrue about the current rider.
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
const held = lookup ? currentAssignee(booking, lookup) : null;
|
||||
if (held) draft.__currentRider = held;
|
||||
}
|
||||
return startFlow(ASSIGN_STEPS, 'assignRider', draft);
|
||||
};
|
||||
|
||||
export const advanceAssign = (flow) => advanceFlow(ASSIGN_STEPS, flow);
|
||||
|
||||
export const answerAssignStep = (flow, raw, option) => answerFlowStep(ASSIGN_STEPS, flow, raw, option);
|
||||
223
src/lib/assistant/bulkFile.js
Normal file
223
src/lib/assistant/bulkFile.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import Papa from 'papaparse';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from '@/lib/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'
|
||||
])
|
||||
);
|
||||
193
src/lib/assistant/bulkFlow.js
Normal file
193
src/lib/assistant/bulkFlow.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { getTenantLocations } from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational bulk create ||============================== //
|
||||
//
|
||||
// Same conversation shape as orderFlow.js — one question per turn, a dropdown
|
||||
// wherever the page uses one — but the rows come from a sheet instead of being
|
||||
// dictated one field at a time. The form version was replaced on explicit
|
||||
// direction: "don't show it as the form way, it should be like chatting".
|
||||
//
|
||||
// The steps deliberately mirror the single-order flow's opening, because they
|
||||
// ARE the same questions: which tenant, which pickup location, which service.
|
||||
// Only the last step differs — a whole file instead of one recipient.
|
||||
//
|
||||
// What is NOT a step: locating and pricing the rows. Those are a long-running
|
||||
// pass over the whole file (~1 lookup/second), so the panel runs them after the
|
||||
// last answer and reports progress into the conversation. Making them a "step"
|
||||
// would mean a question nobody is being asked.
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
export const BULK_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant are these orders for?',
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
// One pickup location for the whole file — the same shape the bulk page
|
||||
// uses, and what makes a single batch dispatchable.
|
||||
ask: 'Which business location are they all picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
// Refused here rather than after submitting: CityGate runs server-side
|
||||
// before the handler, and would reject every row in the file with an
|
||||
// opaque middleware error.
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so every row would be refused. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level for all of them?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'rows',
|
||||
type: 'rows',
|
||||
ask: 'Now the orders themselves — upload a sheet, or paste the rows.',
|
||||
// The whole parse result is stored, not just the rows: the ignored columns
|
||||
// and in-file duplicates have to be reportable, and a count of rows alone
|
||||
// can't say what was quietly not read.
|
||||
validate: (parsed) =>
|
||||
parsed?.rows?.length
|
||||
? null
|
||||
: 'I couldn’t read any complete rows out of that. Every row needs at least a name, a phone and an address.',
|
||||
apply: (d, parsed) => ({ ...d, rows: parsed.rows, __parse: parsed })
|
||||
}
|
||||
];
|
||||
|
||||
export const startBulkFlow = () => {
|
||||
// Same seeding rule as the single-order flow: a client login skips the tenant
|
||||
// question, so the id has to be in the draft or the payload sends NaN.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(BULK_STEPS, 'createBulk', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const advanceBulk = (flow) => advanceFlow(BULK_STEPS, flow);
|
||||
export const answerBulkStep = (flow, raw, option) => answerFlowStep(BULK_STEPS, flow, raw, option);
|
||||
|
||||
// ---- the long pass: locate, then price --------------------------------------
|
||||
//
|
||||
// Extracted from the old form so the conversation can run it and narrate it.
|
||||
// Two economies keep a large file practical, and both are load-bearing:
|
||||
//
|
||||
// • a sheet carrying latitude/longitude columns skips the lookup entirely
|
||||
// • results are cached by address, so a re-run after fixing a few rows does
|
||||
// not re-look-up the ones that were already fine
|
||||
//
|
||||
// `shouldStop` is read through a function, never a captured boolean — as state
|
||||
// it was evaluated once at call time and Stop did nothing for 200 rows.
|
||||
export const GEOCODE_INTERVAL_MS = 1100;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
export const cacheKey = (row) => `${String(row.deliveryaddress || '').toLowerCase()}|${row.deliverypincode || ''}`;
|
||||
export const hasCoords = (row) => Number.isFinite(Number(row.deliverylatitude)) && Number.isFinite(Number(row.deliverylongitude));
|
||||
|
||||
export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgress, shouldStop } = {}) => {
|
||||
const located = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = rows[i];
|
||||
|
||||
if (hasCoords(row)) {
|
||||
located.push(row);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
const key = cacheKey(row);
|
||||
if (cache?.has(key)) {
|
||||
located.push({ ...row, ...cache.get(key) });
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
|
||||
const found = {
|
||||
deliverylatitude: place?.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place?.geometry?.location?.lng?.(),
|
||||
resolvedAddress: place?.formatted_address
|
||||
};
|
||||
cache?.set(key, found);
|
||||
located.push({ ...row, ...found });
|
||||
|
||||
// Only wait after a real request. A cache hit or a sheet coordinate costs
|
||||
// nothing, which is what makes a re-run fast.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Rows never reached because Stop was pressed keep no coordinates, so they
|
||||
// report as unsendable instead of vanishing from the count.
|
||||
if (located.length < rows.length) located.push(...rows.slice(located.length));
|
||||
|
||||
// Only a row that is located AND unpriced needs a routing call. Pricing an
|
||||
// unlocatable row spends an OSRM request just to fail, and re-pricing a row
|
||||
// that carried its own price would overwrite the operator's number.
|
||||
const needPricing = located.filter((r) => hasCoords(r) && String(r.finalprice ?? '') === '');
|
||||
|
||||
// Stop deliberately does NOT gate this phase. It exists to stop the ~1/second
|
||||
// ADDRESS lookups; pricing is unthrottled and bounded by what was already
|
||||
// located. Gating it here meant a Stop mid-lookup left every located row
|
||||
// unpriced and therefore unsendable — throwing away exactly the work the
|
||||
// operator is told is kept.
|
||||
const priced = needPricing.length
|
||||
? await priceBulkRows(needPricing, pickup, tenantid, { onProgress: (p) => onProgress?.({ ...p, phase: 'price' }) })
|
||||
: [];
|
||||
const pricedByLine = new Map(priced.map((r) => [r.line, r]));
|
||||
|
||||
const checked = located.map((r) => {
|
||||
const merged = pricedByLine.get(r.line) || r;
|
||||
return {
|
||||
...merged,
|
||||
// The pricing reason is more specific than "Price must be a number", so it
|
||||
// wins when both apply.
|
||||
error: merged.priceError ? `Couldn’t price it — ${merged.priceError}` : validateBulkRow(merged)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: checked,
|
||||
valid: checked.filter((r) => !r.error),
|
||||
invalid: checked.filter((r) => r.error)
|
||||
};
|
||||
};
|
||||
|
||||
// How many rows still need a network lookup — the only honest basis for an ETA.
|
||||
export const lookupsNeeded = (rows, cache) => rows.filter((r) => !hasCoords(r) && !cache?.has(cacheKey(r))).length;
|
||||
|
||||
export const batchCount = (n) => Math.ceil(n / BULK_MAX);
|
||||
299
src/lib/assistant/bulkOrderActions.js
Normal file
299
src/lib/assistant/bulkOrderActions.js
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createExpressBookingBulk, getAdminPricing } from '@/api/doormile/endpoints';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/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` }))
|
||||
};
|
||||
};
|
||||
142
src/lib/assistant/customerFlow.js
Normal file
142
src/lib/assistant/customerFlow.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-customer ||============================== //
|
||||
//
|
||||
// Asks for one field at a time, then shows what it will send and waits for
|
||||
// Submit.
|
||||
//
|
||||
// ---- Why this lives here and not in the router ------------------------------
|
||||
//
|
||||
// A first attempt at this shipped and broke immediately: the operator typed
|
||||
// "create customer", was asked for a phone number, replied "8494948494", and
|
||||
// got "I can't answer that one yet".
|
||||
//
|
||||
// The cause was architectural, not a typo. `answerQuestion` picks an intent by
|
||||
// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
|
||||
// routed to the fallback and discarded. Threading a partial draft through the
|
||||
// router's `context` didn't help, because the router had already failed to
|
||||
// choose an intent before the draft was ever consulted.
|
||||
//
|
||||
// So the conversation is owned by the PANEL, which checks for an active flow
|
||||
// BEFORE calling the router at all. A reply mid-flow is never routed. That is
|
||||
// the only arrangement where "8494948494" can't be misread as a question.
|
||||
//
|
||||
// Field set and validation mirror pages/nearle/clients/createCustomer.js:
|
||||
// name and a 10-digit phone are required, everything else is optional and
|
||||
// skippable.
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
|
||||
|
||||
// "skip", "none", "no", "-" all mean "leave it blank". Without this the
|
||||
// operator has no way past an optional field except inventing a value.
|
||||
const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
|
||||
|
||||
export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
|
||||
|
||||
// Ordered. `ask` is the question; `apply` folds the answer into the draft;
|
||||
// `validate` returns an error string to re-ask with, or null to accept.
|
||||
export const CUSTOMER_STEPS = [
|
||||
{
|
||||
field: 'name',
|
||||
ask: 'What’s the customer’s name?',
|
||||
required: true,
|
||||
apply: (draft, text) => {
|
||||
const [firstname, ...rest] = String(text).trim().split(/\s+/);
|
||||
return { ...draft, firstname, lastname: rest.join(' ') || undefined };
|
||||
},
|
||||
validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
|
||||
},
|
||||
{
|
||||
field: 'phone',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
required: true,
|
||||
apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
|
||||
// Validated against the digits only, so "98765 43210" and "+91 9876543210"
|
||||
// are both accepted rather than rejected on formatting.
|
||||
validate: (text) => {
|
||||
const digits = String(text)
|
||||
.replace(/\D/g, '')
|
||||
.replace(/^91(?=\d{10}$)/, '');
|
||||
return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
ask: 'Email address? (say “skip” if there isn’t one)',
|
||||
apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
|
||||
validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
|
||||
},
|
||||
{
|
||||
field: 'address',
|
||||
ask: 'Address? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, address: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
ask: 'City? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, city: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'postcode',
|
||||
ask: 'Postcode? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
|
||||
}
|
||||
];
|
||||
|
||||
// Starts the flow, pre-filling anything already said in the opening message —
|
||||
// "create a customer Ramesh 9876543210" should not then ask for the name and
|
||||
// the phone it was just given.
|
||||
export const startCustomerFlow = (text) => {
|
||||
const draft = parseCustomerDraft(text);
|
||||
return advance({ kind: 'createCustomer', step: 0, draft });
|
||||
};
|
||||
|
||||
// Moves to the next step that still needs an answer. Returns either a question
|
||||
// to ask, or the finished proposal.
|
||||
export const advance = (flow) => {
|
||||
let { step } = flow;
|
||||
const { draft } = flow;
|
||||
|
||||
while (step < CUSTOMER_STEPS.length) {
|
||||
const s = CUSTOMER_STEPS[step];
|
||||
const already = s.field === 'name' ? draft.firstname : draft[s.field];
|
||||
if (already) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step }, ask: s.ask, done: false };
|
||||
}
|
||||
|
||||
// Every step visited. The required check is repeated here rather than
|
||||
// trusted from the walk above, so a skipped-but-required field can never
|
||||
// reach a proposal.
|
||||
const { ok, missing } = validateCustomerDraft(draft);
|
||||
if (!ok) {
|
||||
return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
|
||||
}
|
||||
return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
|
||||
};
|
||||
|
||||
// Applies one answer. Returns the next question, or the completed proposal, or
|
||||
// a re-ask when the answer didn't validate.
|
||||
export const answerStep = (flow, text) => {
|
||||
const s = CUSTOMER_STEPS[flow.step];
|
||||
if (!s) return advance(flow);
|
||||
|
||||
if (isSkip(text)) {
|
||||
if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
|
||||
// Skipped optional field: step past it without writing anything, so the
|
||||
// payload builder drops it rather than sending an empty string.
|
||||
return advance({ ...flow, step: flow.step + 1 });
|
||||
}
|
||||
|
||||
const error = s.validate?.(text);
|
||||
if (error) return { flow, ask: error, done: false, retry: true };
|
||||
|
||||
return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
|
||||
};
|
||||
|
||||
// Human-readable summary of what will be sent, for the confirm step.
|
||||
export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));
|
||||
83
src/lib/assistant/flowEngine.js
Normal file
83
src/lib/assistant/flowEngine.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// ==============================|| Doormile AI — conversational flow engine ||============================== //
|
||||
//
|
||||
// One step-walker, shared by every conversational create (orderFlow.js,
|
||||
// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
|
||||
// create became a conversation too — a second copy would have been a third
|
||||
// definition of the same branching rules to keep in sync.
|
||||
//
|
||||
// A step is a plain object:
|
||||
//
|
||||
// id required. Also the draft key the answer lands on.
|
||||
// type 'select' → the panel renders a dropdown (AIFlowStep)
|
||||
// 'rows' → the panel renders the file/paste input
|
||||
// 'text' → answered through the composer
|
||||
// ask the question
|
||||
// when (draft) => boolean. Omitted means always asked. THIS is branching.
|
||||
// options async (draft) => [{ value, label, record? }] — for 'select'
|
||||
// validate (raw, option) => error | null. Re-asks; stores nothing.
|
||||
// resolve async (raw) => { value } | { error }. May fail and re-ask —
|
||||
// geocoding. A value the rest of the flow depends on is never
|
||||
// stored half-resolved.
|
||||
// auto async (draft) => { value?, ask?, patch? }. The step answers itself
|
||||
// from real data and is only ASKED when that fails, with the reason.
|
||||
// apply (draft, value, option) => draft
|
||||
//
|
||||
// A step is skipped when `when` is false OR when `draft[step.id]` is already
|
||||
// set — which is what lets a caller seed the draft (a client login's tenant) or
|
||||
// one step fill several fields (picking an existing customer).
|
||||
|
||||
const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
|
||||
|
||||
// Finds the next step that applies and hasn't been answered. Async because a
|
||||
// step may answer itself from the network before we know whether to ask it.
|
||||
export const advanceFlow = async (steps, flow) => {
|
||||
let { step, draft } = flow;
|
||||
|
||||
while (step < steps.length) {
|
||||
const s = steps[step];
|
||||
if (!applicable(s, draft) || draft[s.id] !== undefined) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.auto) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const auto = await s.auto(draft);
|
||||
if (auto?.patch) draft = { ...draft, ...auto.patch };
|
||||
if (auto?.value !== undefined) {
|
||||
draft = s.apply(draft, auto.value);
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft }, step: s, done: false };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
|
||||
};
|
||||
|
||||
export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
|
||||
|
||||
// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
|
||||
export const answerFlowStep = async (steps, flow, raw, option) => {
|
||||
const s = steps[flow.step];
|
||||
if (!s) return advanceFlow(steps, flow);
|
||||
|
||||
if (s.validate) {
|
||||
const error = s.validate(raw, option);
|
||||
if (error) return { flow, step: s, ask: error, done: false, retry: true };
|
||||
}
|
||||
|
||||
let value = raw;
|
||||
if (s.resolve) {
|
||||
const resolved = await s.resolve(raw);
|
||||
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
|
||||
value = resolved.value;
|
||||
}
|
||||
|
||||
return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
|
||||
};
|
||||
613
src/lib/assistant/flows.js
Normal file
613
src/lib/assistant/flows.js
Normal file
@@ -0,0 +1,613 @@
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
assignMilerToBooking, createExpressBooking, createExpressBookingBulk, createTenantCustomer,
|
||||
getAdminCustomers, getAdminPricing, getalltenants, getallriders, notifyMiler,
|
||||
} from '@/api/doormile';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
|
||||
import { fetchBookingsForDay, scanBookings } from './scan';
|
||||
import { bestNameMatch, bookingCharge, dayFromWords, formatRupees, isCancelled } from './vocab';
|
||||
|
||||
/**
|
||||
* The assistant's five writes: a customer, a single order, a batch, a rider
|
||||
* assignment, and a repeated day.
|
||||
*
|
||||
* **All five are conversations**, one question per turn — not forms. That is
|
||||
* explicit product direction, and there is no create-form component here.
|
||||
*
|
||||
* **The write gate is non-negotiable.** A flow gathers, then shows exactly what
|
||||
* will be sent, and the mutation fires only when the operator presses the
|
||||
* button. `execute` is the only mutating function in this module and nothing
|
||||
* calls it from a `match`. No intent can trigger a write.
|
||||
*
|
||||
* The panel intercepts a reply *before* the router sees it whenever a flow is
|
||||
* open. That is load-bearing rather than a tidy-up: the router matches text, and
|
||||
* a bare answer like `9876543210` matches no intent, so without the intercept
|
||||
* every reply would be lost to "I can't answer that one yet".
|
||||
*/
|
||||
|
||||
/** Customer creation writes to `/admin/tenantcustomers`.
|
||||
*
|
||||
* Settled by evidence: `POST /admin/customers` answers 405 Method Not Allowed —
|
||||
* the route exists and POST is not among its methods. That resource grows a
|
||||
* customer as a side effect of a booking, which is also why its records carry
|
||||
* no address. The Customers page reads tenant customers for the same reason, so
|
||||
* a customer created here appears there immediately.
|
||||
*
|
||||
* Creating one *via a booking* was rejected outright: "add a customer" must
|
||||
* never silently dispatch a delivery.
|
||||
*/
|
||||
const CUSTOMER_STEPS = [
|
||||
{ key: 'firstname', question: 'What is the customer’s first name?', required: true },
|
||||
{ key: 'lastname', question: 'And their last name? (say "skip" if you don’t have it)' },
|
||||
{
|
||||
key: 'phone',
|
||||
question: 'Their 10-digit mobile number?',
|
||||
required: true,
|
||||
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
|
||||
},
|
||||
{ key: 'email', question: 'An email address? (say "skip" if there isn’t one)' },
|
||||
];
|
||||
|
||||
const ORDER_STEPS = [
|
||||
{ key: 'tenant', question: 'Which client is this order for?', required: true },
|
||||
{ key: 'pickupaddress', question: 'Where is it collected from?', required: true },
|
||||
{ key: 'pickupcity', question: 'Which city is the pickup in?', required: true },
|
||||
{ key: 'pickuppincode', question: 'And the pickup pincode?', required: true },
|
||||
{ key: 'customer_name', question: 'Who is receiving it?', required: true },
|
||||
{
|
||||
key: 'customer_phone',
|
||||
question: 'Their 10-digit mobile number?',
|
||||
required: true,
|
||||
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
|
||||
},
|
||||
{ key: 'deliveryaddress', question: 'What is the delivery address?', required: true },
|
||||
{ key: 'deliverycity', question: 'Which city is the delivery in?', required: true },
|
||||
{ key: 'deliverypincode', question: 'And the delivery pincode?', required: true },
|
||||
{
|
||||
key: 'finalprice',
|
||||
question: 'What should this be charged at? (a number, or "skip" to send 0)',
|
||||
validate: (value) =>
|
||||
value === '' || !Number.isNaN(Number(value)) ? null : 'That is not a number — try again.',
|
||||
},
|
||||
{ key: 'notes', question: 'Anything the rider should know? (or "skip")' },
|
||||
];
|
||||
|
||||
const BULK_STEPS = [
|
||||
{ key: 'tenant', question: 'Which client are these orders for?', required: true },
|
||||
{ key: 'pickupaddress', question: 'Where are they all collected from?', required: true },
|
||||
{ key: 'pickupcity', question: 'Which city is that pickup in?', required: true },
|
||||
{ key: 'pickuppincode', question: 'And its pincode?', required: true },
|
||||
{
|
||||
key: 'rows',
|
||||
question:
|
||||
'Now paste the recipients — one per line, as:\n\nname, phone, address, city, pincode\n\nPaste them all in one message.',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Assign or reassign one order to a rider.
|
||||
*
|
||||
* The backend already assigns riders on its own (booking creation publishes an
|
||||
* assignment-requested event that a worker picks up within minutes) — this
|
||||
* flow is always an OVERRIDE of a decision the backend may have already made.
|
||||
* That is why the second step re-states who currently holds the order rather
|
||||
* than silently overwriting them: an operator who has not been told is far
|
||||
* more likely to reassign a rider who was already correctly, better-informed,
|
||||
* on their way.
|
||||
*
|
||||
* Unlike the create flows, both steps here need a live lookup (find the order,
|
||||
* then find the rider) rather than a plain string — see `resolve` below.
|
||||
*/
|
||||
const ASSIGN_STEPS = [
|
||||
{
|
||||
key: 'orderRef',
|
||||
question: 'Which order? (the order number, or the DM-… code)',
|
||||
required: true,
|
||||
resolve: async (value) => {
|
||||
const scan = await scanBookings();
|
||||
const needle = String(value).trim().toLowerCase().replace(/^#/, '');
|
||||
const booking = scan.rows.find(
|
||||
(row) =>
|
||||
String(row.bookingid).toLowerCase() === needle ||
|
||||
String(row.bookingno || '').toLowerCase() === needle ||
|
||||
String(row.bookingno || '').toLowerCase().endsWith(needle)
|
||||
);
|
||||
if (!booking) return { error: `No order matches "${value}" — try the order number again.` };
|
||||
return { value: booking };
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'riderRef',
|
||||
/* Names who currently has it, if anyone — the whole point of asking rather
|
||||
than just overwriting. */
|
||||
question: (values) => {
|
||||
const order = values.orderRef;
|
||||
const label = order?.bookingno || `#${order?.bookingid}`;
|
||||
return order?.assignedmileruserid
|
||||
? `${label} is currently assigned. Who should it go to instead?`
|
||||
: `Who should ${label} go to?`;
|
||||
},
|
||||
required: true,
|
||||
resolve: async (value, values) => {
|
||||
const riders = (await getallriders()) || [];
|
||||
const rider = bestNameMatch(value, riders, (r) => r.displayname || r.authname);
|
||||
if (!rider) return { error: `No rider matches "${value}" — try their name again.` };
|
||||
if (String(rider.userid) === String(values.orderRef?.assignedmileruserid)) {
|
||||
return { error: `${rider.displayname || rider.authname} already has this order — name someone else.` };
|
||||
}
|
||||
return { value: rider };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Repeat a previous day's dispatched orders as fresh bookings today.
|
||||
*
|
||||
* One question (which day), then a single automated pass — not a run of
|
||||
* questions — because a booking already carries almost everything
|
||||
* `createExpressBooking` needs, including both sets of coordinates. Only the
|
||||
* recipient's name and phone are missing, and those come from the
|
||||
* `appcustomerid` → `/admin/customers` join, the same one `fetchDeliveries`
|
||||
* already does.
|
||||
*
|
||||
* Duplicate-safety is INVERTED here versus every other flow: near-identical
|
||||
* orders are the *goal*. The only real risk is running the same day's repeat
|
||||
* twice, so the guard fingerprints on `(appcustomerid, delivery address,
|
||||
* pickup pincode)` against TODAY's own bookings, not against the source day.
|
||||
*
|
||||
* Prices are re-quoted at today's tariff — `getAdminPricing` + the same
|
||||
* OSRM-distance-and-tariff formula `CreateOrder.jsx` already uses — never
|
||||
* copied from the original booking, since a tariff can have changed since.
|
||||
* Cancelled orders are never repeated, and the lookback is 7 days.
|
||||
*/
|
||||
const REPEAT_STEPS = [
|
||||
{
|
||||
key: 'sourceDay',
|
||||
question: 'Which day should I repeat? (say "yesterday", or a date like 2026-08-20)',
|
||||
required: true,
|
||||
resolve: async (value) => {
|
||||
const day = dayFromWords(value);
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
if (day > today) return { error: 'That is in the future — nothing to repeat yet.' };
|
||||
if (day < dayjs().subtract(7, 'day').format('YYYY-MM-DD')) {
|
||||
return { error: 'That is more than 7 days back — repeat only looks at the last week.' };
|
||||
}
|
||||
|
||||
const [sourceScan, todayScan, customers] = await Promise.all([
|
||||
fetchBookingsForDay(day),
|
||||
fetchBookingsForDay(today),
|
||||
getAdminCustomers().catch(() => []),
|
||||
]);
|
||||
|
||||
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
|
||||
const fingerprint = (booking) =>
|
||||
`${booking.appcustomerid}|${String(booking.deliveryaddress || '').toLowerCase().trim()}|${booking.pickuppincode || ''}`;
|
||||
const alreadyRepeated = new Set(todayScan.rows.map(fingerprint));
|
||||
|
||||
const dispatched = sourceScan.rows.filter((b) => b.assignedmileruserid || b.consignmentid);
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
dispatched.forEach((booking) => {
|
||||
/* Cancelled orders are never repeated — not even reported as skipped,
|
||||
since there is nothing an operator would do about that reason. */
|
||||
if (isCancelled(booking)) return;
|
||||
if (alreadyRepeated.has(fingerprint(booking))) {
|
||||
skipped.push({ booking, reason: 'already repeated today' });
|
||||
return;
|
||||
}
|
||||
if (!booking.pickupaddress) {
|
||||
skipped.push({ booking, reason: 'no pickup address recorded on the original order' });
|
||||
return;
|
||||
}
|
||||
if (booking.tenantid == null) {
|
||||
skipped.push({ booking, reason: 'no client recorded on the original order' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(Number(booking.deliverylatitude)) || !Number.isFinite(Number(booking.deliverylongitude))) {
|
||||
skipped.push({ booking, reason: 'delivery address has no saved coordinates' });
|
||||
return;
|
||||
}
|
||||
const customer = customerMap.get(booking.appcustomerid);
|
||||
const phone = customer?.phone || customer?.contactno;
|
||||
if (!phone) {
|
||||
skipped.push({ booking, reason: 'no customer contact number on file' });
|
||||
return;
|
||||
}
|
||||
candidates.push({ booking, customer_name: customer.firstname || customer.name || '', customer_phone: phone });
|
||||
});
|
||||
|
||||
if (!candidates.length) {
|
||||
return {
|
||||
error: skipped.length
|
||||
? `None of the ${skipped.length} dispatched order(s) from ${day} can be repeated — every one is missing something.`
|
||||
: `No dispatched orders were found on ${day}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/* Re-quoted per candidate, not fetched once for the day — a repeat can
|
||||
span more than one client, and each client's own pricing row decides
|
||||
the number (same reasoning as the bulk-create flow's per-tenant rate
|
||||
lookup, just per-row here instead of per-file). */
|
||||
const pricing = await getAdminPricing().catch(() => []);
|
||||
const priced = await Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
const rate = pricing.find((row) => String(row.tenantid) === String(candidate.booking.tenantid));
|
||||
if (!rate) return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'no pricing configured for this client — used the previous price' };
|
||||
try {
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: candidate.booking.pickuplatitude, longitude: candidate.booking.pickuplongitude },
|
||||
{ latitude: candidate.booking.deliverylatitude, longitude: candidate.booking.deliverylongitude }
|
||||
);
|
||||
const price = calculateTotalCharge(km, Number(rate.baseprice) || 0, Number(rate.priceperkm) || 0, Number(rate.basedistance) || 0);
|
||||
return { ...candidate, finalprice: Number(price.toFixed(2)) };
|
||||
} catch {
|
||||
return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'could not re-measure the route — used the previous price' };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { value: { day, candidates: priced, skipped } };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const FLOWS = {
|
||||
customer: { title: 'New customer', steps: CUSTOMER_STEPS },
|
||||
order: { title: 'New order', steps: ORDER_STEPS },
|
||||
bulk: { title: 'Bulk orders', steps: BULK_STEPS },
|
||||
assign: { title: 'Assign a rider', steps: ASSIGN_STEPS },
|
||||
repeat: { title: 'Repeat a run', steps: REPEAT_STEPS },
|
||||
};
|
||||
|
||||
/* "assign"/"reassign" + rider word, or "assign order/it/this/DM-…", or "change
|
||||
the rider" — matched ahead of the create-verb gate below since "assign" is
|
||||
not one of those verbs. */
|
||||
const ASSIGN_TRIGGER =
|
||||
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it\b|this\b|that\b|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
|
||||
|
||||
/* "repeat yesterday('s run/orders)", "same orders as yesterday", "redo
|
||||
yesterday" — also matched ahead of the create-verb gate, since "repeat" and
|
||||
"redo" are not among those verbs either. */
|
||||
const REPEAT_TRIGGER =
|
||||
/\brepeat\s+(?:yesterday|today|the\s+run|last\s+\w+|orders?)\b|\bsame\s+orders?\s+as\s+(?:yesterday|last\s+\w+)\b|\bredo\s+(?:yesterday|the\s+run)\b/i;
|
||||
|
||||
/** Which flow, if any, a question is asking to start. */
|
||||
export const detectFlow = (text) => {
|
||||
const lower = String(text || '').toLowerCase();
|
||||
if (ASSIGN_TRIGGER.test(lower)) return 'assign';
|
||||
if (REPEAT_TRIGGER.test(lower)) return 'repeat';
|
||||
if (!/\b(create|add|new|make|place|raise|book)\b/.test(lower)) return null;
|
||||
if (/\bcustomer\b/.test(lower)) return 'customer';
|
||||
if (/\b(bulk|multiple|many|batch of)\b/.test(lower) && /\border/.test(lower)) return 'bulk';
|
||||
if (/\b(order|booking|delivery)\b/.test(lower)) return 'order';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const startFlow = (kind) => {
|
||||
const flow = FLOWS[kind];
|
||||
if (!flow) return null;
|
||||
const first = flow.steps[0];
|
||||
const question = typeof first.question === 'function' ? first.question({}) : first.question;
|
||||
return { kind, title: flow.title, stepIndex: 0, values: {}, question };
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies one reply and returns the next state.
|
||||
*
|
||||
* Returns `{ flow }` while still gathering, or `{ flow, review }` once every
|
||||
* step is answered — `review` is the exact payload that will be sent, which the
|
||||
* operator confirms before anything is written.
|
||||
*
|
||||
* Async because a step's `resolve` (assign flow's order/rider lookup) may need
|
||||
* a live API call; the create flows' steps have no `resolve` and pass through
|
||||
* exactly as before.
|
||||
*/
|
||||
export const advanceFlow = async (flow, reply) => {
|
||||
const steps = FLOWS[flow.kind].steps;
|
||||
const step = steps[flow.stepIndex];
|
||||
const raw = String(reply || '').trim();
|
||||
const skipped = /^(skip|none|no|n\/a)$/i.test(raw);
|
||||
const value = skipped ? '' : raw;
|
||||
|
||||
if (step.required && !value) {
|
||||
return { flow, error: 'That one is required — please answer it.' };
|
||||
}
|
||||
if (value && step.validate) {
|
||||
const problem = step.validate(value);
|
||||
if (problem) return { flow, error: problem };
|
||||
}
|
||||
|
||||
let resolved = value;
|
||||
if (value && step.resolve) {
|
||||
const outcome = await step.resolve(value, flow.values);
|
||||
if (outcome.error) return { flow, error: outcome.error };
|
||||
resolved = outcome.value;
|
||||
}
|
||||
|
||||
const values = { ...flow.values, [step.key]: resolved };
|
||||
const nextIndex = flow.stepIndex + 1;
|
||||
|
||||
if (nextIndex < steps.length) {
|
||||
const nextStep = steps[nextIndex];
|
||||
const question = typeof nextStep.question === 'function' ? nextStep.question(values) : nextStep.question;
|
||||
return { flow: { ...flow, stepIndex: nextIndex, values, question } };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, stepIndex: nextIndex, values, question: null }, review: buildReview(flow.kind, values) };
|
||||
};
|
||||
|
||||
/** A human-readable summary plus the payload that will actually be sent. */
|
||||
const buildReview = (kind, values) => {
|
||||
if (kind === 'repeat') {
|
||||
const { day, candidates, skipped } = values.sourceDay;
|
||||
return {
|
||||
kind,
|
||||
title: `Repeat ${candidates.length} order${candidates.length === 1 ? '' : 's'} from ${day}?`,
|
||||
lines: [
|
||||
['Source day', day],
|
||||
['Will create', `${candidates.length} order${candidates.length === 1 ? '' : 's'}`],
|
||||
skipped.length ? ['Skipping', `${skipped.length} — see below`] : null,
|
||||
].filter(Boolean),
|
||||
preview: [
|
||||
...candidates
|
||||
.slice(0, 5)
|
||||
.map((c) => `${c.customer_name || 'Customer'} · ${c.booking.deliveryaddress} · ${formatRupees(c.finalprice)}`),
|
||||
...skipped.slice(0, 3).map((s) => `Skipped ${s.booking.bookingno || `#${s.booking.bookingid}`} — ${s.reason}`),
|
||||
],
|
||||
values: { day, candidates, skipped },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'assign') {
|
||||
const order = values.orderRef;
|
||||
const rider = values.riderRef;
|
||||
return {
|
||||
kind,
|
||||
title: 'Assign this order?',
|
||||
lines: [
|
||||
['Order', order.bookingno || `#${order.bookingid}`],
|
||||
['To', rider.displayname || rider.authname],
|
||||
['Phone', rider.phone],
|
||||
].filter(([, v]) => v),
|
||||
values: {
|
||||
bookingid: order.bookingid,
|
||||
bookingLabel: order.bookingno || `#${order.bookingid}`,
|
||||
mileruserid: rider.userid,
|
||||
milerprofileid: rider.milerprofileid,
|
||||
riderName: rider.displayname || rider.authname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'customer') {
|
||||
return {
|
||||
kind,
|
||||
title: 'Create this customer?',
|
||||
lines: [
|
||||
['Name', [values.firstname, values.lastname].filter(Boolean).join(' ')],
|
||||
['Phone', values.phone],
|
||||
['Email', values.email],
|
||||
].filter(([, v]) => v),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'order') {
|
||||
return {
|
||||
kind,
|
||||
title: 'Create this order?',
|
||||
lines: [
|
||||
['Client', values.tenant],
|
||||
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
|
||||
['Recipient', `${values.customer_name} · ${values.customer_phone}`],
|
||||
['Drop', `${values.deliveryaddress}, ${values.deliverycity} ${values.deliverypincode}`],
|
||||
['Charge', values.finalprice ? formatRupees(Number(values.finalprice)) : formatRupees(0)],
|
||||
['Notes', values.notes],
|
||||
].filter(([, v]) => v),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseBulkRows(values.rows);
|
||||
return {
|
||||
kind,
|
||||
title: `Create ${parsed.length} order${parsed.length === 1 ? '' : 's'}?`,
|
||||
lines: [
|
||||
['Client', values.tenant],
|
||||
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
|
||||
['Recipients', `${parsed.length} rows`],
|
||||
],
|
||||
preview: parsed.slice(0, 5).map((row) => `${row.customer_name} · ${row.customer_phone} · ${row.deliveryaddress}`),
|
||||
values: { ...values, parsed },
|
||||
};
|
||||
};
|
||||
|
||||
/** `name, phone, address, city, pincode` per line. Blank lines are ignored. */
|
||||
const parseBulkRows = (text) =>
|
||||
String(text || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode] = line
|
||||
.split(',')
|
||||
.map((cell) => cell.trim());
|
||||
return { customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode };
|
||||
})
|
||||
.filter((row) => row.customer_name && row.customer_phone);
|
||||
|
||||
const resolveTenantId = async (name) => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
const tenant = bestNameMatch(name, tenants, (t) => t.tenantname);
|
||||
return tenant?.tenantid ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The only mutating function in this module. Called from the review card's
|
||||
* button and from nowhere else.
|
||||
*/
|
||||
export const executeFlow = async (review) => {
|
||||
const { kind, values } = review;
|
||||
|
||||
if (kind === 'repeat') {
|
||||
const bookings = values.candidates.map(({ booking, customer_name, customer_phone, finalprice }) => ({
|
||||
tenantid: booking.tenantid,
|
||||
pickupaddress: booking.pickupaddress,
|
||||
pickupcity: booking.pickupcity || '',
|
||||
pickuppincode: booking.pickuppincode || '',
|
||||
pickuplatitude: booking.pickuplatitude,
|
||||
pickuplongitude: booking.pickuplongitude,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
deliveryaddress: booking.deliveryaddress,
|
||||
deliverycity: booking.deliverycity || '',
|
||||
deliverypincode: booking.deliverypincode || '',
|
||||
deliverylatitude: booking.deliverylatitude,
|
||||
deliverylongitude: booking.deliverylongitude,
|
||||
service_option: 'Normal',
|
||||
finalprice,
|
||||
notes: booking.notes || '',
|
||||
parcels: booking.parcels?.length
|
||||
? booking.parcels.map((p) => ({
|
||||
itemcategory: p.itemcategory || 'General',
|
||||
itemdescription: p.itemdescription || 'Order',
|
||||
declaredvalue: p.declaredvalue || 0,
|
||||
}))
|
||||
: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: finalprice }],
|
||||
}));
|
||||
|
||||
/* Repeats are scoped to one calendar day, so this should never approach
|
||||
the bulk endpoint's 200-per-call cap — refused rather than silently
|
||||
truncated on the rare day that does. */
|
||||
if (bookings.length > 200) {
|
||||
return { ok: false, message: `That is ${bookings.length} orders; the bulk endpoint takes 200 at a time.` };
|
||||
}
|
||||
|
||||
const result = await createExpressBookingBulk(bookings);
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
|
||||
|
||||
/* Two response shapes have been seen from this endpoint in practice — read
|
||||
defensively rather than assume one. A shape this doesn't recognise just
|
||||
means the reassignment pass below finds nothing to do; the orders
|
||||
themselves are still created either way. */
|
||||
const created = result?.results || result?.data?.results || [];
|
||||
|
||||
/* Straight back to whoever had it, one order at a time — sequential on
|
||||
purpose, since these are real writes against dispatch records and a
|
||||
burst of concurrent calls makes a partial failure much harder to
|
||||
attribute to the right order. */
|
||||
let reassigned = 0;
|
||||
for (let i = 0; i < values.candidates.length; i += 1) {
|
||||
const riderId = values.candidates[i].booking.assignedmileruserid;
|
||||
const createdId = created[i]?.bookingid ?? created[i]?.id;
|
||||
if (riderId && createdId) {
|
||||
try {
|
||||
await assignMilerToBooking(createdId, { mileruserid: Number(riderId) });
|
||||
reassigned += 1;
|
||||
} catch {
|
||||
/* The create already succeeded — a failed reassignment leaves that
|
||||
order pending rather than undoing it. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [
|
||||
`${bookings.length} order${bookings.length === 1 ? '' : 's'} created from ${values.day}.`,
|
||||
reassigned ? `${reassigned} went straight back to their previous rider.` : null,
|
||||
values.skipped.length ? `${values.skipped.length} from the original day could not be repeated.` : null,
|
||||
].filter(Boolean);
|
||||
return { ok: true, message: parts.join(' ') };
|
||||
}
|
||||
|
||||
if (kind === 'assign') {
|
||||
const result = await assignMilerToBooking(values.bookingid, { mileruserid: Number(values.mileruserid) });
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The order was not assigned.' };
|
||||
|
||||
/* The assignment already landed at this point — a failed push is reported,
|
||||
not treated as the write having failed. */
|
||||
let notified = false;
|
||||
if (values.milerprofileid) {
|
||||
try {
|
||||
await notifyMiler(values.milerprofileid, 'DoormileXpress', 'A new order has been assigned to you.');
|
||||
notified = true;
|
||||
} catch {
|
||||
/* swallowed — reported via the message below instead */
|
||||
}
|
||||
}
|
||||
|
||||
const tail = notified
|
||||
? ' They have been notified.'
|
||||
: values.milerprofileid
|
||||
? ' The notification failed — tell them directly.'
|
||||
: ' This rider has no profile id, so no notification could be sent.';
|
||||
return { ok: true, message: `${values.bookingLabel} is now with ${values.riderName}.${tail}` };
|
||||
}
|
||||
|
||||
if (kind === 'customer') {
|
||||
const result = await createTenantCustomer({
|
||||
firstname: values.firstname,
|
||||
lastname: values.lastname || '',
|
||||
phone: values.phone,
|
||||
email: values.email || '',
|
||||
});
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The customer was not created.' };
|
||||
return {
|
||||
ok: true,
|
||||
message: `${values.firstname} ${values.lastname || ''}`.trim() + ' was added. They appear on the Customers page now.',
|
||||
};
|
||||
}
|
||||
|
||||
const tenantid = await resolveTenantId(values.tenant);
|
||||
if (!tenantid) {
|
||||
return { ok: false, message: `No client matches "${values.tenant}" — nothing was created.` };
|
||||
}
|
||||
|
||||
if (kind === 'order') {
|
||||
const price = Number(values.finalprice) || 0;
|
||||
const result = await createExpressBooking({
|
||||
tenantid,
|
||||
pickupaddress: values.pickupaddress,
|
||||
pickupcity: values.pickupcity,
|
||||
pickuppincode: values.pickuppincode,
|
||||
customer_name: values.customer_name,
|
||||
customer_phone: values.customer_phone,
|
||||
deliveryaddress: values.deliveryaddress,
|
||||
deliverycity: values.deliverycity,
|
||||
deliverypincode: values.deliverypincode,
|
||||
service_option: 'Normal',
|
||||
finalprice: price,
|
||||
notes: values.notes || '',
|
||||
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: price }],
|
||||
});
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The order was not created.' };
|
||||
return { ok: true, message: 'Order created. It is on the Orders page under Pending.' };
|
||||
}
|
||||
|
||||
const bookings = (values.parsed || []).map((row) => ({
|
||||
tenantid,
|
||||
pickupaddress: values.pickupaddress,
|
||||
pickupcity: values.pickupcity,
|
||||
pickuppincode: values.pickuppincode,
|
||||
customer_name: row.customer_name,
|
||||
customer_phone: row.customer_phone,
|
||||
deliveryaddress: row.deliveryaddress || '',
|
||||
deliverycity: row.deliverycity || '',
|
||||
deliverypincode: row.deliverypincode || '',
|
||||
service_option: 'Normal',
|
||||
finalprice: 0,
|
||||
notes: '',
|
||||
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: 0 }],
|
||||
}));
|
||||
|
||||
/* The endpoint takes at most 200 per call — refuse rather than silently
|
||||
truncating the operator's list. */
|
||||
if (bookings.length > 200) {
|
||||
return { ok: false, message: `That is ${bookings.length} rows; this endpoint takes 200 at a time. Split the list.` };
|
||||
}
|
||||
|
||||
const result = await createExpressBookingBulk(bookings);
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
|
||||
return { ok: true, message: `${bookings.length} orders created. They are on the Orders page under Pending.` };
|
||||
};
|
||||
2319
src/lib/assistant/intents.js
Normal file
2319
src/lib/assistant/intents.js
Normal file
File diff suppressed because it is too large
Load Diff
152
src/lib/assistant/orderActions.js
Normal file
152
src/lib/assistant/orderActions.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createExpressBooking, getTenantLocations } from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
|
||||
// ==============================|| Doormile AI — create order ||============================== //
|
||||
//
|
||||
// Second write capability. Same contract as actions.js: nothing here mutates
|
||||
// except executeCreateOrder, which the panel calls only when the operator
|
||||
// presses Create.
|
||||
//
|
||||
// Order creation is materially riskier than customer creation — a wrong record
|
||||
// dispatches a real rider.
|
||||
//
|
||||
// ⚠ `pickuplocationid` DOES NOT WORK. express-console-api.md documents it as
|
||||
// the way to reference a stored pickup site, and this file was originally
|
||||
// built on it, but createorder1.js records (in two places) that the field
|
||||
// live-500s on POST /admin/expressbooking — a reported backend bug, with no
|
||||
// frontend workaround other than not using it. The page therefore always
|
||||
// sends the RAW pickup fields, copying them out of the chosen saved location.
|
||||
//
|
||||
// This does the same. The operator still picks a saved location — that part
|
||||
// is good UX and keeps CityGate satisfied, since a stored site has already
|
||||
// passed it — but what goes on the wire is pickupaddress / pickuppincode /
|
||||
// pickupcity / pickuplatitude / pickuplongitude, exactly as the page sends.
|
||||
|
||||
export const CREATE_ORDER_TRIGGER = /\b(?:create|add|place|book|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:order|booking|delivery)\b/i;
|
||||
|
||||
// Doormile's own service tiers (express-console-api.md).
|
||||
export const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
|
||||
|
||||
// CityGate is enforced server-side, before the handler runs. Checking it here
|
||||
// too means the operator is told which cities are open BEFORE submitting,
|
||||
// instead of getting an opaque middleware rejection back.
|
||||
export const OPEN_CITY_PREFIXES = {
|
||||
641: 'Coimbatore',
|
||||
600: 'Chennai',
|
||||
560: 'Bengaluru',
|
||||
500: 'Hyderabad',
|
||||
629: 'Nagercoil'
|
||||
};
|
||||
|
||||
export const cityGateFor = (pincode) => {
|
||||
const prefix = String(pincode || '').slice(0, 3);
|
||||
return OPEN_CITY_PREFIXES[prefix] || null;
|
||||
};
|
||||
|
||||
export const loadOrderTenants = async () => (await getAdminTenants()) || [];
|
||||
|
||||
// Saved pickup sites for a tenant. Each carries address / city / pincode /
|
||||
// coordinates — which is what the payload copies onto the booking, since
|
||||
// `pickuplocationid` itself 500s (see the note at the top of this file).
|
||||
export const loadPickupLocations = async (tenantid) => {
|
||||
if (!tenantid) return [];
|
||||
return (await getTenantLocations(tenantid)) || [];
|
||||
};
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
|
||||
// Mirrors createorder1.js's own gate, plus the two rules the endpoint enforces
|
||||
// that the page leaves to the server (non-empty parcels, CityGate).
|
||||
export const validateOrderDraft = (d) => {
|
||||
const errors = {};
|
||||
if (!d.tenantid) errors.tenantid = 'Choose a tenant';
|
||||
// Still required: the operator picks a saved site so its address/pincode/
|
||||
// coordinates can be copied onto the booking. The id itself is never sent.
|
||||
if (!d.pickuplocationid) errors.pickuplocationid = 'Choose a pickup location';
|
||||
if (!d.customer_name?.trim()) errors.customer_name = 'Required';
|
||||
if (!PHONE_RE.test(String(d.customer_phone || '').trim())) errors.customer_phone = 'Enter exactly 10 digits';
|
||||
if (!d.deliveryaddress?.trim()) errors.deliveryaddress = 'Required';
|
||||
if (!String(d.deliverypincode || '').trim()) errors.deliverypincode = 'Required';
|
||||
if (!d.itemdescription?.trim()) errors.itemdescription = 'Describe what is being sent';
|
||||
if (d.finalprice === '' || d.finalprice == null || Number.isNaN(Number(d.finalprice))) errors.finalprice = 'Enter an amount';
|
||||
|
||||
// Delivery coordinates come from the address search. Without them the
|
||||
// optimiser has nothing to route against, so refuse rather than send a
|
||||
// booking that can never be dispatched.
|
||||
if (!Number.isFinite(Number(d.deliverylatitude)) || !Number.isFinite(Number(d.deliverylongitude))) {
|
||||
errors.deliveryaddress = 'Pick the address from the suggestions so coordinates are captured';
|
||||
}
|
||||
|
||||
return { ok: Object.keys(errors).length === 0, errors };
|
||||
};
|
||||
|
||||
// The exact body that will be POSTed. Mirrors createorder1.js's own payload —
|
||||
// raw pickup fields, never `pickuplocationid` (see the note at the top).
|
||||
//
|
||||
// `pickup` is the saved location record the operator chose; its address,
|
||||
// pincode, city and coordinates are copied onto the booking.
|
||||
export const buildOrderPayload = (d, pickup) => ({
|
||||
tenantid: Number(d.tenantid),
|
||||
pickupaddress: pickup?.address || '',
|
||||
pickuppincode: String(pickup?.pincode || ''),
|
||||
pickupcity: pickup?.city || '',
|
||||
pickuplatitude: Number(pickup?.latitude) || 0,
|
||||
pickuplongitude: Number(pickup?.longitude) || 0,
|
||||
customer_name: d.customer_name.trim(),
|
||||
customer_phone: String(d.customer_phone).trim(),
|
||||
deliveryaddress: d.deliveryaddress.trim(),
|
||||
deliverypincode: String(d.deliverypincode).trim(),
|
||||
deliverycity: d.deliverycity || '',
|
||||
deliverylatitude: Number(d.deliverylatitude),
|
||||
deliverylongitude: Number(d.deliverylongitude),
|
||||
service_option: SERVICE_OPTIONS.includes(d.service_option) ? d.service_option : 'Normal',
|
||||
finalprice: Number(d.finalprice),
|
||||
notes: d.notes || '',
|
||||
// A parcel entry has no quantity field — N items means N entries, which is
|
||||
// how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
|
||||
parcels: Array.from({ length: Math.max(1, Number(d.quantity) || 1) }, () => ({
|
||||
itemcategory: d.itemcategory || 'General',
|
||||
itemdescription: d.itemdescription.trim(),
|
||||
declaredvalue: Number(d.declaredvalue) || 0
|
||||
}))
|
||||
});
|
||||
|
||||
// The only order-writing function in the assistant.
|
||||
export const executeCreateOrder = async (payload) => {
|
||||
const started = Date.now();
|
||||
const call = {
|
||||
name: 'createExpressBooking',
|
||||
target: 'POST /admin/expressbooking',
|
||||
stats: `tenant ${payload.tenantid}, ${payload.parcels.length} parcel${payload.parcels.length === 1 ? '' : 's'}`
|
||||
};
|
||||
try {
|
||||
const res = await createExpressBooking(payload);
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server rejected the order.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
|
||||
};
|
||||
}
|
||||
const created = res?.data || res;
|
||||
return {
|
||||
ok: true,
|
||||
id: created?.bookingid ?? created?.id,
|
||||
bookingno: created?.bookingno,
|
||||
created,
|
||||
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created ${created?.bookingno || created?.bookingid || '—'}` }]
|
||||
};
|
||||
} catch (err) {
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
const message =
|
||||
status === 404 || status === 405
|
||||
? `POST /admin/expressbooking returned ${status} — that route does not exist on the server.`
|
||||
: status
|
||||
? `POST /admin/expressbooking returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`
|
||||
: `${serverMessage || 'The request failed'} — nothing was saved.`;
|
||||
return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
|
||||
}
|
||||
};
|
||||
268
src/lib/assistant/orderFlow.js
Normal file
268
src/lib/assistant/orderFlow.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { getAdminPricing, getAdminCustomers, getTenantLocations } from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-order ||============================== //
|
||||
//
|
||||
// One question at a time, mirroring createorder1.js's own field set and using a
|
||||
// DROPDOWN wherever that page uses one — business location, customer, category,
|
||||
// weight, service tier. Free text is only for things that genuinely are free
|
||||
// text (a name, an address, a description).
|
||||
//
|
||||
// Two capabilities the customer flow didn't need:
|
||||
//
|
||||
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
|
||||
// straight to picking from the real customer list, new asks for the fields.
|
||||
// Steps carry a `when` predicate and are skipped when it's false.
|
||||
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
|
||||
// dropdown never shows a stale or invented list.
|
||||
//
|
||||
// The conversation is driven by the PANEL, which intercepts replies before the
|
||||
// router ever sees them — see customerFlow.js for why that matters (a bare
|
||||
// "8494948494" matches no intent and used to be discarded).
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const digits = (t) => String(t || '').replace(/\D/g, '');
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
// ---- steps ------------------------------------------------------------------
|
||||
//
|
||||
// type: 'select' → the panel renders a dropdown from `options(draft)`
|
||||
// 'text' → answered through the composer
|
||||
// when: omitted means always asked
|
||||
export const ORDER_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant is this order for?',
|
||||
// A client login already has its tenant; only Doormile staff choose.
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
ask: 'Which business location is this picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
// The pincode is shown because it decides CityGate — a location outside
|
||||
// the open cities will be refused server-side, and the operator should
|
||||
// see that before choosing rather than after submitting.
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'customerMode',
|
||||
type: 'select',
|
||||
ask: 'Is this an existing customer, or a new one?',
|
||||
options: async () => [
|
||||
{ value: 'existing', label: 'Existing customer' },
|
||||
{ value: 'new', label: 'New customer' }
|
||||
],
|
||||
apply: (d, v) => ({ ...d, customerMode: v })
|
||||
},
|
||||
{
|
||||
id: 'existingCustomer',
|
||||
type: 'select',
|
||||
ask: 'Which customer?',
|
||||
when: (d) => d.customerMode === 'existing',
|
||||
options: async () => {
|
||||
const customers = (await getAdminCustomers()) || [];
|
||||
return customers
|
||||
.filter((c) => c.phone)
|
||||
.map((c) => ({
|
||||
value: String(c.appcustomerid ?? c.id),
|
||||
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
|
||||
record: c
|
||||
}));
|
||||
},
|
||||
// Picking an existing customer fills the name and phone, so the two
|
||||
// free-text steps below are skipped by their own `when`.
|
||||
apply: (d, v, option) => ({
|
||||
...d,
|
||||
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
|
||||
customer_phone: digits(option?.record?.phone)
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'customer_name',
|
||||
type: 'text',
|
||||
ask: 'What’s the customer’s name?',
|
||||
when: (d) => d.customerMode === 'new' && !d.customer_name,
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
|
||||
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'customer_phone',
|
||||
type: 'text',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
when: (d) => !d.customer_phone,
|
||||
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
|
||||
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
|
||||
},
|
||||
{
|
||||
id: 'deliveryaddress',
|
||||
type: 'text',
|
||||
ask: 'Where is it being delivered? Give the full address.',
|
||||
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
|
||||
// an address that can't be located is refused here rather than becoming a
|
||||
// booking nothing can dispatch.
|
||||
resolve: async (t) => {
|
||||
const place = await geocodeAddress(String(t).trim()).catch(() => null);
|
||||
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
|
||||
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
|
||||
(place.address_components || []).forEach((c) => {
|
||||
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
|
||||
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
|
||||
});
|
||||
return {
|
||||
value: {
|
||||
...parts,
|
||||
deliverylatitude: place.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place.geometry?.location?.lng?.()
|
||||
}
|
||||
};
|
||||
},
|
||||
apply: (d, v) => ({ ...d, ...v })
|
||||
},
|
||||
{
|
||||
id: 'deliverypincode',
|
||||
type: 'text',
|
||||
ask: 'What’s the delivery pincode?',
|
||||
when: (d) => !d.deliverypincode,
|
||||
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
|
||||
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'itemcategory',
|
||||
type: 'select',
|
||||
ask: 'What kind of parcel is it?',
|
||||
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
|
||||
apply: (d, v) => ({ ...d, itemcategory: v })
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
type: 'select',
|
||||
ask: 'Roughly how heavy?',
|
||||
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
|
||||
apply: (d, v) => ({ ...d, weight: v })
|
||||
},
|
||||
{
|
||||
id: 'itemdescription',
|
||||
type: 'text',
|
||||
ask: 'Briefly, what’s inside?',
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
|
||||
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
type: 'select',
|
||||
ask: 'How many parcels?',
|
||||
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
|
||||
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
|
||||
},
|
||||
{
|
||||
id: 'finalprice',
|
||||
type: 'text',
|
||||
ask: 'What should the price be? Enter the amount in ₹.',
|
||||
// `auto` answers a step from real data and only falls back to asking. The
|
||||
// quote is stashed either way so the confirmation can show the distance it
|
||||
// measured, and say why it couldn’t price when it couldn’t.
|
||||
auto: async (d) => {
|
||||
const quote = await priceOrder(d);
|
||||
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
|
||||
return {
|
||||
patch: { __quote: quote },
|
||||
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
|
||||
};
|
||||
},
|
||||
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
|
||||
apply: (d, t) => ({ ...d, finalprice: Number(t) })
|
||||
}
|
||||
];
|
||||
|
||||
// Mirrors createorder1.js's own lists so the bot offers the same choices.
|
||||
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
|
||||
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
|
||||
|
||||
// ---- pricing ----------------------------------------------------------------
|
||||
//
|
||||
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
|
||||
// this tenant's own pricing row. Quoted, never invented — if no pricing row
|
||||
// matches, the operator is asked for the amount rather than shown a zero.
|
||||
export const priceOrder = async (draft) => {
|
||||
const tid = draft.tenantid || localStorage.getItem('tenantid');
|
||||
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(tid));
|
||||
|
||||
const pickup = draft.__pickup;
|
||||
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
|
||||
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: pickup.latitude, longitude: pickup.longitude },
|
||||
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
|
||||
).catch(() => null);
|
||||
|
||||
if (km == null) return { error: 'could not measure the distance' };
|
||||
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
|
||||
|
||||
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
|
||||
return {
|
||||
km,
|
||||
durationMin: getLastRouteDurationMin(),
|
||||
basePrice: match.baseprice,
|
||||
total: Number(Number(total).toFixed(2))
|
||||
};
|
||||
};
|
||||
|
||||
// ---- engine -----------------------------------------------------------------
|
||||
//
|
||||
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
|
||||
// These wrappers keep the order-specific names the panel and the tests use.
|
||||
|
||||
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
|
||||
|
||||
export const startOrderFlow = () => {
|
||||
// A client login already belongs to a tenant, so its step is skipped — but
|
||||
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
|
||||
// draft is what makes the skip safe.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);
|
||||
103
src/lib/assistant/pageContext.js
Normal file
103
src/lib/assistant/pageContext.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* What the assistant knows about where the operator is standing.
|
||||
*
|
||||
* Every page offers every question. The assistant answers about orders, riders,
|
||||
* hubs and the rest regardless of which screen is open, so hiding a question
|
||||
* because you happen to be on Dispatch made it look narrower than it is. The
|
||||
* page decides the ORDER — its own questions lead — not the membership.
|
||||
*
|
||||
* Every suggestion here must actually resolve against the intent catalog. A
|
||||
* chip that comes back "I can't answer that yet" is worse than no chip.
|
||||
*/
|
||||
|
||||
const CATALOG = [
|
||||
'How many orders today?',
|
||||
'How many orders are cancelled?',
|
||||
'Morning batch orders',
|
||||
'Revenue this week',
|
||||
'How many riders are active?',
|
||||
'How many clients do we have?',
|
||||
'How many hubs?',
|
||||
'How many vehicles?',
|
||||
'Any open exceptions?',
|
||||
'How many tripsheets are dispatched?',
|
||||
'How many customers?',
|
||||
'How many pricing rules?',
|
||||
'Track consignment DM-CN-1',
|
||||
'Assign a rider to an order',
|
||||
"Repeat yesterday's run",
|
||||
'Compare orders today vs yesterday',
|
||||
'Status of hub Chennai',
|
||||
'Find vehicle TN01AB1234',
|
||||
'How many competitor branches are tracked?',
|
||||
'How many carrier pricing entries do we have?',
|
||||
];
|
||||
|
||||
const BY_ROUTE = [
|
||||
[
|
||||
/^\/doormile\/dispatch/,
|
||||
'Dispatch',
|
||||
['Morning batch orders', 'How many riders are active?', "Repeat yesterday's run", 'How many orders today?'],
|
||||
],
|
||||
[
|
||||
/^\/doormile\/orders/,
|
||||
'Orders',
|
||||
['How many orders today?', 'Assign a rider to an order', 'How many orders are cancelled?', 'Revenue this week'],
|
||||
],
|
||||
[/^\/doormile\/deliveries/, 'Deliveries', ['How many orders are delivered?', 'Track consignment DM-CN-1', 'Morning batch orders']],
|
||||
[/^\/doormile\/riders/, 'Riders', ['How many riders are active?', 'Where is Murali?']],
|
||||
[/^\/doormile\/tenants/, 'Clients', ['How many clients do we have?', 'How many pricing rules?']],
|
||||
[/^\/doormile\/customers/, 'Customers', ['How many customers?', 'Create a customer']],
|
||||
[/^\/doormile\/pricing/, 'Pricing', ['How many pricing rules?', 'Revenue this week']],
|
||||
[/^\/doormile\/hubs/, 'Hubs', ['How many hubs?', 'Status of hub Chennai', 'How many vehicles?']],
|
||||
[/^\/doormile\/vehicles/, 'Vehicles', ['How many vehicles?', 'Find vehicle TN01AB1234', 'How many hubs?']],
|
||||
[/^\/doormile\/tripsheets/, 'Tripsheets', ['How many tripsheets are dispatched?', 'How many consignments?']],
|
||||
[/^\/doormile\/exceptions/, 'Exceptions', ['Any open exceptions?', 'How many consignments?']],
|
||||
[
|
||||
/^\/doormile\/competitive-intel/,
|
||||
'Competitive Intel',
|
||||
['How many competitor branches are tracked?', 'How many carrier pricing entries do we have?'],
|
||||
],
|
||||
[/^\/doormile\/reports/, 'Reports', ['Revenue this week', 'Compare orders today vs yesterday', 'How many orders today?']],
|
||||
];
|
||||
|
||||
/** `{ label, suggestions }` for a route — its own questions first, then the rest. */
|
||||
export const getPageContext = (pathname) => {
|
||||
const hit = BY_ROUTE.find(([pattern]) => pattern.test(pathname || ''));
|
||||
const label = hit ? hit[1] : 'Console';
|
||||
const leading = hit ? hit[2] : [];
|
||||
/* Deduplicated, leading questions first — one flat list means one place a
|
||||
question can live. */
|
||||
const suggestions = [...new Set([...leading, ...CATALOG])];
|
||||
return { label, suggestions };
|
||||
};
|
||||
|
||||
export const CHIP_LABELS = {
|
||||
'How many orders today?': 'Orders today',
|
||||
'How many orders are cancelled?': 'Cancelled',
|
||||
'Morning batch orders': 'Morning batch',
|
||||
'Revenue this week': 'Revenue',
|
||||
'How many riders are active?': 'Active riders',
|
||||
'How many clients do we have?': 'Clients',
|
||||
'How many hubs?': 'Hubs',
|
||||
'How many vehicles?': 'Vehicles',
|
||||
'Any open exceptions?': 'Exceptions',
|
||||
'How many tripsheets are dispatched?': 'Tripsheets',
|
||||
'How many customers?': 'Customers',
|
||||
'How many pricing rules?': 'Pricing rules',
|
||||
'How many orders are delivered?': 'Delivered',
|
||||
'Where is Murali?': 'Locate Murali',
|
||||
'Create a customer': 'New customer',
|
||||
'How many consignments?': 'Consignments',
|
||||
'Track consignment DM-CN-1': 'Track DM-CN-1',
|
||||
'Assign a rider to an order': 'Assign rider',
|
||||
'Repeat yesterday\'s run': 'Repeat run',
|
||||
'Compare orders today vs yesterday': 'Compare orders',
|
||||
'Status of hub Chennai': 'Chennai hub',
|
||||
'Find vehicle TN01AB1234': 'Find vehicle',
|
||||
'How many competitor branches are tracked?': 'Competitor branches',
|
||||
'How many carrier pricing entries do we have?': 'Carrier pricing'
|
||||
};
|
||||
|
||||
export const ORDER_CREATED = '__orderCreated';
|
||||
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';
|
||||
66
src/lib/assistant/ragRouter.js
Normal file
66
src/lib/assistant/ragRouter.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// ==============================|| Doormile AI — semantic routing client ||============================== //
|
||||
//
|
||||
// Talks to the retrieval sidecar (services/ai) to decide WHICH QUESTION was
|
||||
// asked. It never returns data — every figure still comes from the intent's own
|
||||
// deterministic run(), through the same typed API functions the pages use.
|
||||
//
|
||||
// The whole module is optional by design:
|
||||
//
|
||||
// • REACT_APP_AI_URL unset → disabled, regex matcher only (production today)
|
||||
// • sidecar unreachable → disabled for this call, regex matcher
|
||||
// • slow → aborted at ROUTE_TIMEOUT_MS, regex matcher
|
||||
// • low confidence → not used, regex matcher
|
||||
//
|
||||
// Today's behaviour is the floor. This can raise it, never lower it.
|
||||
|
||||
const BASE = import.meta.env.VITE_AI_URL || import.meta.env.REACT_APP_AI_URL || '';
|
||||
const ROUTE_TIMEOUT_MS = 400;
|
||||
|
||||
export const isRagEnabled = () => Boolean(BASE);
|
||||
|
||||
// Once the sidecar has failed we stop hammering it on every keystroke-fast
|
||||
// question. Re-armed after a cool-off so a restarted container is picked up
|
||||
// without a page reload.
|
||||
let disabledUntil = 0;
|
||||
const COOL_OFF_MS = 30000;
|
||||
|
||||
const post = async (path, body) => {
|
||||
if (!BASE || Date.now() < disabledUntil) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
// Any failure — offline, timeout, 5xx — is silent by design. The operator
|
||||
// gets the deterministic answer; they should never see plumbing.
|
||||
disabledUntil = Date.now() + COOL_OFF_MS;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns { intentId, confidence, score, margin, alternatives } or null.
|
||||
export const routeQuestion = (text) => post('/route', { text });
|
||||
|
||||
// Documentation passages, verbatim with attribution. No generation step —
|
||||
// summarising would need a hosted model (assistant/CLAUDE.md §2) and would let
|
||||
// a paraphrase drift from what the doc actually says.
|
||||
export const askDocs = (text) => post('/ask', { text });
|
||||
|
||||
// A semantic near-miss must never open a create form. Write intents require
|
||||
// high confidence AND corroboration from the deterministic trigger, so the
|
||||
// worst case is that the operator types the phrase the regex already knows.
|
||||
export const isRouteTrustworthy = (routed) => {
|
||||
if (!routed?.intentId) return false;
|
||||
if (routed.isWrite) return routed.confidence === 'high';
|
||||
return routed.confidence === 'high' || routed.confidence === 'medium';
|
||||
};
|
||||
30
src/lib/assistant/repeatFlow.js
Normal file
30
src/lib/assistant/repeatFlow.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { findRecentRuns, describeDay } from './repeatRuns';
|
||||
import { startFlow, advanceFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a run ||============================== //
|
||||
//
|
||||
// One question: which day. Everything after it — resolving customers, the drift
|
||||
// check, re-quoting at today's tariff, the already-repeated guard — is a pass
|
||||
// the panel runs and narrates, exactly like the bulk file's locate/price phase.
|
||||
// None of it is a question, so none of it is a step.
|
||||
|
||||
export const REPEAT_STEPS = [
|
||||
{
|
||||
id: 'day',
|
||||
type: 'select',
|
||||
ask: 'Which day’s orders should I repeat?',
|
||||
options: async () => {
|
||||
const { runs } = await findRecentRuns();
|
||||
return runs.map((r) => ({
|
||||
value: r.day,
|
||||
label: `${describeDay(r.day)} — ${r.count} order${r.count === 1 ? '' : 's'}`,
|
||||
record: r
|
||||
}));
|
||||
},
|
||||
apply: (d, v, option) => ({ ...d, day: v, sourceCount: option?.record?.count })
|
||||
}
|
||||
];
|
||||
|
||||
export const startRepeatFlow = () => startFlow(REPEAT_STEPS, 'repeatRun', {});
|
||||
export const advanceRepeat = (flow) => advanceFlow(REPEAT_STEPS, flow);
|
||||
export const answerRepeatStep = (flow, raw, option) => answerFlowStep(REPEAT_STEPS, flow, raw, option);
|
||||
244
src/lib/assistant/repeatRuns.js
Normal file
244
src/lib/assistant/repeatRuns.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getAdminCustomers } from '@/api/doormile/endpoints';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { groupForBookingStatus } from '@/lib/orderStatusGroups';
|
||||
import { scanBookings } from './intents';
|
||||
import { cityGateFor } from './orderActions';
|
||||
import { priceBulkRows } from './bulkOrderActions';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a past day's run ||============================== //
|
||||
//
|
||||
// "I created ten orders yesterday; make the same ten today."
|
||||
//
|
||||
// This is the cheapest write in the assistant, and the reason is worth stating:
|
||||
// a booking already carries 15 of the 17 fields `buildOrderPayload` needs —
|
||||
// both addresses, both pincodes, BOTH SETS OF COORDINATES, the service tier,
|
||||
// the parcels. Only the recipient's name and phone are missing, and those come
|
||||
// from the `appcustomerid` → /admin/customers join the assistant already does.
|
||||
//
|
||||
// So a repeat needs NO geocoding. The ~1 lookup/second Nominatim throttle that
|
||||
// dominates the bulk-file flow does not apply here at all.
|
||||
//
|
||||
// ---- A booking is a snapshot, not a template --------------------------------
|
||||
//
|
||||
// Which is why every row goes through a drift check before it can be repeated.
|
||||
// These are not hypothetical; all three were found in one live page of 36
|
||||
// bookings:
|
||||
//
|
||||
// • `pickupaddress` can be ABSENT entirely (booking 57 carries the pincode
|
||||
// and coordinates but no address key at all) — repeating it blind sends an
|
||||
// empty pickup address.
|
||||
// • `tenantid` can be null (every `Customer_App` booking) — `Number(null)`
|
||||
// is 0, so the payload would claim tenant zero.
|
||||
// • a pickup pincode that was open when the order was placed may not be now,
|
||||
// and CityGate refuses at the middleware, before the handler runs.
|
||||
//
|
||||
// ---- Duplicate safety is INVERTED here --------------------------------------
|
||||
//
|
||||
// Everywhere else in this assistant, near-identical orders are an error to
|
||||
// prevent (`wasAlreadySubmitted`). A repeat run deliberately creates them, so
|
||||
// that guard would misfire on every single run. The question that actually
|
||||
// matters is different: *has this run already been repeated today?* The
|
||||
// endpoint has no idempotency key, so it is answered by looking: today's own
|
||||
// bookings are fingerprinted and any row already present is set aside rather
|
||||
// than booked twice.
|
||||
|
||||
export const REPEAT_TRIGGER = /\b(?:repeat|redo|re-?run)\b|\bsame\s+orders?\s+as\b|\bsame\s+as\s+(?:yesterday|last)\b/i;
|
||||
|
||||
// How far back a run can be recalled from. Beyond a week it stops being "the
|
||||
// usual round" and starts being archaeology.
|
||||
const LOOKBACK_DAYS = 7;
|
||||
|
||||
const dayOf = (value) => {
|
||||
const t = parseDoormileTimestamp(value);
|
||||
return t.isValid() ? t.format('YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
// A row is only worth repeating if it was a real order. Cancelled ones are
|
||||
// excluded — repeating a cancellation is never what "same as yesterday" means.
|
||||
const isRepeatable = (b) => groupForBookingStatus(b.status) !== 'cancelled';
|
||||
|
||||
// ---- which days have a run to repeat ---------------------------------------
|
||||
export const findRecentRuns = async () => {
|
||||
const scan = await scanBookings();
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const counts = new Map();
|
||||
|
||||
(scan.rows || []).forEach((b) => {
|
||||
if (!isRepeatable(b)) return;
|
||||
const day = dayOf(b.createdat);
|
||||
if (!day || day === today) return;
|
||||
if (dayjs(today).diff(dayjs(day), 'day') > LOOKBACK_DAYS) return;
|
||||
counts.set(day, (counts.get(day) || 0) + 1);
|
||||
});
|
||||
|
||||
return {
|
||||
scan,
|
||||
runs: [...counts.entries()].map(([day, count]) => ({ day, count })).sort((a, b) => (a.day < b.day ? 1 : -1))
|
||||
};
|
||||
};
|
||||
|
||||
export const describeDay = (day) => {
|
||||
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||
if (day === yesterday) return 'Yesterday';
|
||||
return dayjs(day).format('ddd D MMM');
|
||||
};
|
||||
|
||||
// ---- one booking → one repeatable row ---------------------------------------
|
||||
//
|
||||
// `__pickup` travels on the ROW, not on the shared draft: a day's run can span
|
||||
// several kitchens and tenants, and collapsing them onto one shared pickup
|
||||
// would silently re-address half the orders.
|
||||
const toRow = (booking, customer, index) => ({
|
||||
line: index + 1,
|
||||
source: booking.bookingno || `#${booking.bookingid}`,
|
||||
tenantid: booking.tenantid,
|
||||
customer_name: customer?.name || [customer?.firstname, customer?.lastname].filter(Boolean).join(' ') || '',
|
||||
customer_phone: String(customer?.phone || customer?.contactno || '').replace(/\D/g, ''),
|
||||
deliveryaddress: booking.deliveryaddress || '',
|
||||
deliverypincode: String(booking.deliverypincode || ''),
|
||||
deliverycity: booking.deliverycity || '',
|
||||
deliverylatitude: booking.deliverylatitude,
|
||||
deliverylongitude: booking.deliverylongitude,
|
||||
service_option: booking.serviceoptions?.[0]?.servicetype || 'Normal',
|
||||
// Deliberately blank: the chosen behaviour is to re-quote at today's tariff,
|
||||
// so this is left for priceBulkRows to fill exactly as an unpriced bulk row
|
||||
// would be. Copying yesterday's number would silently bill an old tariff.
|
||||
finalprice: '',
|
||||
previousPrice: booking.serviceoptions?.[0]?.estimatedprice,
|
||||
itemcategory: booking.parcels?.[0]?.itemcategory || 'General',
|
||||
itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
|
||||
quantity: Math.max(1, booking.parcels?.length || 1),
|
||||
notes: booking.notes || '',
|
||||
// Who ran this drop last time. Carried so the repeat can hand the new order
|
||||
// back to the same rider — they already know the door, the buzzer and the
|
||||
// customer. Null when yesterday's order was never assigned, which is a
|
||||
// normal case and simply means the copy stays pending.
|
||||
__previousMilerUserId: booking.assignedmileruserid ?? null,
|
||||
__pickup: {
|
||||
address: booking.pickupaddress,
|
||||
pincode: booking.pickuppincode,
|
||||
city: booking.pickupcity,
|
||||
latitude: booking.pickuplatitude,
|
||||
longitude: booking.pickuplongitude
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the drift check --------------------------------------------------------
|
||||
//
|
||||
// Returns a REASON, never a boolean — an operator dropping a row deserves to
|
||||
// know which field went stale.
|
||||
const driftReason = (row) => {
|
||||
if (!row.tenantid) return 'the original had no tenant, so this would be booked against tenant 0';
|
||||
if (!row.__pickup?.address) return 'the original booking carries no pickup address';
|
||||
if (!row.customer_phone) return 'the customer record is gone, so there is no phone number';
|
||||
if (!row.customer_name) return 'the customer record is gone, so there is no name';
|
||||
if (!row.deliveryaddress) return 'no delivery address on the original';
|
||||
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
|
||||
return 'the original has no delivery coordinates, so it could never be routed';
|
||||
}
|
||||
if (!cityGateFor(row.__pickup.pincode)) {
|
||||
return `its pickup pincode (${row.__pickup.pincode || 'unknown'}) is no longer a city Doormile serves`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// What makes two orders "the same order". It has to include the PARCEL, not
|
||||
// just the destination: a run to one address for one customer is a completely
|
||||
// normal thing to do twice in a day with different contents, and judging on
|
||||
// phone + address + pickup alone made three unrelated bookings to the same bus
|
||||
// stand look identical — the repeat then reported all of yesterday as already
|
||||
// created when only one unrelated order existed today.
|
||||
//
|
||||
// Every field here is one a repeat reproduces EXACTLY, which is what makes it a
|
||||
// usable identity. Price is deliberately excluded: it is re-quoted at today's
|
||||
// tariff, so it differs by design on every legitimate repeat.
|
||||
const fingerprint = (row) =>
|
||||
[
|
||||
row.customer_phone,
|
||||
String(row.deliveryaddress || '').toLowerCase(),
|
||||
row.__pickup?.pincode || '',
|
||||
row.service_option,
|
||||
row.itemcategory,
|
||||
row.itemdescription,
|
||||
row.quantity
|
||||
].join('|');
|
||||
|
||||
// ---- assemble the run -------------------------------------------------------
|
||||
export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
|
||||
const [{ scan }, customers] = await Promise.all([findRecentRuns(), getAdminCustomers().catch(() => [])]);
|
||||
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.id, c]));
|
||||
|
||||
const source = (scan.rows || []).filter((b) => isRepeatable(b) && dayOf(b.createdat) === day);
|
||||
const rows = source.map((b, i) => toRow(b, customerMap.get(b.appcustomerid), i));
|
||||
|
||||
// Already repeated today? Today's own bookings are put through the SAME
|
||||
// `toRow` shaping, so both sides of the comparison get identical defaults —
|
||||
// hand-rolling the today side is how the two drifted apart in the first place.
|
||||
//
|
||||
// Counts, not a Set. A Set answers "does anything today look like this", so a
|
||||
// single matching order suppressed EVERY row that shared its fingerprint —
|
||||
// one order created today wiped out all of yesterday's. A multiset answers
|
||||
// the question that was actually meant: how many of these already exist. Two
|
||||
// identical orders yesterday and one today means one still needs creating.
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const todayCounts = new Map();
|
||||
(scan.rows || [])
|
||||
.filter((b) => isRepeatable(b) && dayOf(b.createdat) === today)
|
||||
.forEach((b) => {
|
||||
const key = fingerprint(toRow(b, customerMap.get(b.appcustomerid), 0));
|
||||
todayCounts.set(key, (todayCounts.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const drifted = [];
|
||||
const already = [];
|
||||
const candidates = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const reason = driftReason(row);
|
||||
if (reason) {
|
||||
drifted.push({ ...row, error: reason });
|
||||
return;
|
||||
}
|
||||
// Consume one match per already-existing order, so a second identical row
|
||||
// is still offered once the first has been accounted for.
|
||||
const key = fingerprint(row);
|
||||
const remaining = todayCounts.get(key) || 0;
|
||||
if (remaining > 0) {
|
||||
todayCounts.set(key, remaining - 1);
|
||||
already.push(row);
|
||||
return;
|
||||
}
|
||||
candidates.push(row);
|
||||
});
|
||||
|
||||
// Re-quote at today's tariff. Priced per row because a run can span tenants,
|
||||
// and a tenant's own pricing row is what decides the number.
|
||||
const priced = [];
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = candidates[i];
|
||||
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
|
||||
priced.push(out);
|
||||
}
|
||||
if (priced.length < candidates.length) priced.push(...candidates.slice(priced.length));
|
||||
|
||||
const valid = priced.filter((r) => !r.priceError && Number(r.finalprice) > 0);
|
||||
const unpriced = priced.filter((r) => r.priceError || !(Number(r.finalprice) > 0));
|
||||
|
||||
return {
|
||||
day,
|
||||
scan,
|
||||
total: rows.length,
|
||||
valid,
|
||||
drifted,
|
||||
already,
|
||||
unpriced,
|
||||
// Every price that moved since the original, so a tariff change is visible
|
||||
// rather than discovered on an invoice.
|
||||
changed: valid.filter((r) => r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice))
|
||||
};
|
||||
};
|
||||
145
src/lib/assistant/scan.js
Normal file
145
src/lib/assistant/scan.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { getBookingsPage } from '@/api/doormile';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
/**
|
||||
* Reading bookings for the assistant.
|
||||
*
|
||||
* `GET /admin/bookings` has no date, status or tenant filter and caps `pagesize`
|
||||
* at 1000 server-side, so every question is answered by draining pages and
|
||||
* filtering here. A single `getBookings(1, 1000)` silently under-reports the
|
||||
* moment an account passes 1000 lifetime bookings, which is why nothing in the
|
||||
* assistant calls it.
|
||||
*
|
||||
* Every scan returns `{ rows, truncated, scanned, pagesFetched, total }` rather
|
||||
* than a bare array, and **`truncated` is not optional to handle**. A count
|
||||
* built on a capped scan is a floor, not a total — run it through `countPhrase`,
|
||||
* append `truncationNote` to the detail, and build the audit entry with
|
||||
* `scanCall`, which reports an error rather than a green tick beside a partial
|
||||
* number.
|
||||
*/
|
||||
|
||||
const BULK_PAGESIZE = 1000;
|
||||
|
||||
/** 12k rows — generous, but bounded so one question cannot hammer the API. */
|
||||
const MAX_PAGES = 12;
|
||||
|
||||
/**
|
||||
* Row order is not documented. When page 1 comes back newest-first the scan can
|
||||
* stop as soon as a page ends older than the window; otherwise it scans to the
|
||||
* budget. Detected per call rather than assumed, so a backend change degrades to
|
||||
* "scan everything" — slower, still correct — rather than to a wrong answer.
|
||||
*/
|
||||
const isDescendingByCreatedAt = (rows) => {
|
||||
if (rows.length < 2) return false;
|
||||
const first = parseDoormileTimestamp(rows[0].createdat);
|
||||
const last = parseDoormileTimestamp(rows[rows.length - 1].createdat);
|
||||
return first.isValid() && last.isValid() && first.valueOf() > last.valueOf();
|
||||
};
|
||||
|
||||
const inRange = (booking, start, end) => {
|
||||
const at = parseDoormileTimestamp(booking.createdat);
|
||||
if (!at.isValid()) return false;
|
||||
const day = at.format('YYYY-MM-DD');
|
||||
return day >= start && day <= end;
|
||||
};
|
||||
|
||||
/**
|
||||
* A short-lived page cache.
|
||||
*
|
||||
* A multi-part question runs several intents, and a comparison runs two ranges —
|
||||
* each would otherwise re-drain the same pages. Deliberately small and local:
|
||||
* this is not a caching layer to settle on.
|
||||
*/
|
||||
const PAGE_CACHE_TTL_MS = 20_000;
|
||||
const pageCache = new Map();
|
||||
|
||||
const getPageCached = (page) => {
|
||||
const hit = pageCache.get(page);
|
||||
if (hit && Date.now() - hit.at < PAGE_CACHE_TTL_MS) return hit.promise;
|
||||
|
||||
const promise = getBookingsPage(page, BULK_PAGESIZE).catch((err) => {
|
||||
/* Never cache a failure — the next question should retry, not inherit it. */
|
||||
pageCache.delete(page);
|
||||
throw err;
|
||||
});
|
||||
pageCache.set(page, { at: Date.now(), promise });
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const fetchBookingsInRange = async (start, end) => {
|
||||
const firstPage = await getPageCached(1);
|
||||
const total = firstPage.total;
|
||||
const pageCount = Math.max(1, Math.ceil(total / BULK_PAGESIZE));
|
||||
const budget = Math.min(pageCount, MAX_PAGES);
|
||||
|
||||
const collected = [...firstPage.rows];
|
||||
const descending = isDescendingByCreatedAt(firstPage.rows);
|
||||
|
||||
/* Newest-first and this page already ends before the window opens → every
|
||||
later page is older still, so there is nothing left to find. */
|
||||
const pageEndsBeforeRange = (rows) => {
|
||||
if (!descending || !rows.length) return false;
|
||||
const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
|
||||
return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
|
||||
};
|
||||
|
||||
let stoppedEarly = pageEndsBeforeRange(firstPage.rows);
|
||||
let lastPageFetched = 1;
|
||||
|
||||
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
|
||||
const next = await getPageCached(page);
|
||||
lastPageFetched = page;
|
||||
if (!next.rows.length) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
collected.push(...next.rows);
|
||||
stoppedEarly = pageEndsBeforeRange(next.rows);
|
||||
}
|
||||
|
||||
return {
|
||||
rows: collected.filter((booking) => inRange(booking, start, end)),
|
||||
/* Truncated only if the budget ran out with pages still unread AND the scan
|
||||
did not stop early because it had already passed the window. */
|
||||
truncated: !stoppedEarly && pageCount > budget,
|
||||
scanned: collected.length,
|
||||
pagesFetched: lastPageFetched,
|
||||
total,
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
|
||||
|
||||
/**
|
||||
* A full scan with no date window — for a per-order lookup, which has to look
|
||||
* everywhere rather than inside a range. The sentinel bounds keep one code path:
|
||||
* they can never trigger the early stop, so this always drains to the page
|
||||
* budget and reports `truncated` honestly if the id could be further back.
|
||||
*/
|
||||
export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31');
|
||||
|
||||
/** A count over a truncated scan is a floor — say so. */
|
||||
export const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`;
|
||||
|
||||
export const truncationNote = (scan) =>
|
||||
scan.truncated
|
||||
? `\n\nScanned the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
|
||||
'en-IN'
|
||||
)} bookings — this is a floor, not a complete count.`
|
||||
: '';
|
||||
|
||||
/**
|
||||
* The audit entry for a scan. Reports the real page count, and flags itself as
|
||||
* an error when truncated so the sources strip cannot show a green "complete"
|
||||
* beside a partial number.
|
||||
*/
|
||||
export const scanCall = (scan, note) => ({
|
||||
name: 'getBookingsPage',
|
||||
target: `/admin/bookings (${scan.pagesFetched} page${scan.pagesFetched === 1 ? '' : 's'} × ${BULK_PAGESIZE})`,
|
||||
status: scan.truncated ? 'error' : 'complete',
|
||||
errorMessage: scan.truncated ? `Scan capped at ${MAX_PAGES} pages; ${scan.total} bookings exist` : undefined,
|
||||
stats: note,
|
||||
});
|
||||
|
||||
/** A plain audit entry for a non-scan call. */
|
||||
export const call = (name, target, stats) => ({ name, target, status: 'complete', stats });
|
||||
342
src/lib/assistant/vocab.js
Normal file
342
src/lib/assistant/vocab.js
Normal file
@@ -0,0 +1,342 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getRowBatchId } from '@/lib/batchBucket';
|
||||
import { ORDER_STATUS_GROUPS, ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus } from '@/lib/orderStatusGroups';
|
||||
import { getalltenants } from '@/api/doormile';
|
||||
|
||||
/**
|
||||
* The assistant's fixed vocabulary — dates, statuses, batches, typo tolerance.
|
||||
*
|
||||
* Deliberately a small hand-written vocabulary rather than a date-parsing
|
||||
* library: an unrecognised phrase falls back to today, never to a guessed date.
|
||||
* A wrong date silently answers a different question, which is worse than
|
||||
* refusing.
|
||||
*/
|
||||
|
||||
/* ── Typo tolerance ───────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Only these words are ever "corrected". Order numbers, tenant names and short
|
||||
* words are never touched, so a correction can never invent a term the operator
|
||||
* did not mean.
|
||||
*/
|
||||
const KEYWORD_VOCAB = [
|
||||
'orders', 'order', 'bookings', 'booking', 'delivered', 'delivery', 'deliveries',
|
||||
'cancelled', 'canceled', 'cancellation', 'pending', 'assigned', 'active',
|
||||
'riders', 'rider', 'milers', 'miler', 'tenants', 'tenant', 'clients', 'client',
|
||||
'customers', 'customer', 'hubs', 'vehicles', 'vehicle', 'tripsheets', 'tripsheet',
|
||||
'exceptions', 'exception', 'pricing', 'revenue', 'today', 'yesterday', 'tomorrow',
|
||||
'morning', 'afternoon', 'evening', 'batch', 'status', 'summary', 'total',
|
||||
'available', 'offline', 'blocked', 'week', 'month', 'consignments', 'consignment',
|
||||
'partners', 'partner', 'profitability', 'profit',
|
||||
];
|
||||
|
||||
const levenshtein = (a, b) => {
|
||||
if (a === b) return 0;
|
||||
const rows = Array.from({ length: b.length + 1 }, (_, i) => [i, ...Array(a.length).fill(0)]);
|
||||
for (let j = 1; j <= a.length; j += 1) rows[0][j] = j;
|
||||
for (let i = 1; i <= b.length; i += 1) {
|
||||
for (let j = 1; j <= a.length; j += 1) {
|
||||
rows[i][j] = Math.min(
|
||||
rows[i - 1][j] + 1,
|
||||
rows[i][j - 1] + 1,
|
||||
rows[i - 1][j - 1] + (a[j - 1] === b[i - 1] ? 0 : 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows[b.length][a.length];
|
||||
};
|
||||
|
||||
/**
|
||||
* Corrects misspelled domain keywords before matching. Words shorter than five
|
||||
* characters are left alone — at that length almost anything is within edit
|
||||
* distance of something, and "correcting" it changes the question.
|
||||
*/
|
||||
export const correctTypos = (text) =>
|
||||
String(text)
|
||||
.split(/(\s+)/)
|
||||
.map((token) => {
|
||||
const word = token.toLowerCase();
|
||||
if (word.length < 5 || /[\d#-]/.test(word)) return token;
|
||||
if (KEYWORD_VOCAB.includes(word)) return token;
|
||||
const tolerance = word.length >= 8 ? 2 : 1;
|
||||
const hit = KEYWORD_VOCAB.find((candidate) => levenshtein(word, candidate) <= tolerance);
|
||||
return hit || token;
|
||||
})
|
||||
.join('');
|
||||
|
||||
/* ── Dates ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||
|
||||
const today = () => dayjs().format('YYYY-MM-DD');
|
||||
|
||||
/** An explicit DD/MM/YYYY or ISO date, or null. */
|
||||
const explicitDateFromWords = (text) => {
|
||||
const iso = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
|
||||
if (iso) return iso[0];
|
||||
const slash = text.match(/\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b/);
|
||||
if (slash) {
|
||||
const parsed = dayjs(`${slash[3]}-${slash[2].padStart(2, '0')}-${slash[1].padStart(2, '0')}`);
|
||||
if (parsed.isValid()) return parsed.format('YYYY-MM-DD');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The most recent past occurrence of a named weekday. */
|
||||
const weekdayFromWords = (text) => {
|
||||
const hit = WEEKDAYS.find((day) => new RegExp(`\\b${day}\\b`, 'i').test(text));
|
||||
if (!hit) return null;
|
||||
const target = WEEKDAYS.indexOf(hit);
|
||||
let cursor = dayjs();
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
cursor = cursor.subtract(1, 'day');
|
||||
if (cursor.day() === target) return cursor.format('YYYY-MM-DD');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Whether the question named a date at all — as opposed to us defaulting. */
|
||||
export const mentionsAnyDate = (text) =>
|
||||
/\b(today|yesterday|tomorrow|this week|last week|this month|last month|from .+ to )\b/i.test(text) ||
|
||||
Boolean(explicitDateFromWords(text)) ||
|
||||
Boolean(weekdayFromWords(text));
|
||||
|
||||
/** A single day named in the question, defaulting to today. */
|
||||
export const dayFromWords = (text) => {
|
||||
const explicit = explicitDateFromWords(text);
|
||||
if (explicit) return explicit;
|
||||
if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||
if (/\btomorrow\b/i.test(text)) return dayjs().add(1, 'day').format('YYYY-MM-DD');
|
||||
const weekday = weekdayFromWords(text);
|
||||
if (weekday) return weekday;
|
||||
return today();
|
||||
};
|
||||
|
||||
/** A `{ start, end, label }` window named in the question, defaulting to today. */
|
||||
export const rangeFromWords = (text) => {
|
||||
const explicitPair = text.match(/from\s+(\S+)\s+to\s+(\S+)/i);
|
||||
if (explicitPair) {
|
||||
const start = explicitDateFromWords(explicitPair[1]) || dayFromWords(explicitPair[1]);
|
||||
const end = explicitDateFromWords(explicitPair[2]) || dayFromWords(explicitPair[2]);
|
||||
if (start && end) return { start, end, label: `${start} to ${end}` };
|
||||
}
|
||||
if (/\blast week\b/i.test(text)) {
|
||||
const base = dayjs().subtract(1, 'week');
|
||||
return {
|
||||
start: base.startOf('week').format('YYYY-MM-DD'),
|
||||
end: base.endOf('week').format('YYYY-MM-DD'),
|
||||
label: 'last week',
|
||||
};
|
||||
}
|
||||
if (/\bthis week\b/i.test(text)) {
|
||||
return {
|
||||
start: dayjs().startOf('week').format('YYYY-MM-DD'),
|
||||
end: dayjs().endOf('week').format('YYYY-MM-DD'),
|
||||
label: 'this week',
|
||||
};
|
||||
}
|
||||
if (/\blast month\b/i.test(text)) {
|
||||
const base = dayjs().subtract(1, 'month');
|
||||
return {
|
||||
start: base.startOf('month').format('YYYY-MM-DD'),
|
||||
end: base.endOf('month').format('YYYY-MM-DD'),
|
||||
label: 'last month',
|
||||
};
|
||||
}
|
||||
if (/\bthis month\b/i.test(text)) {
|
||||
return {
|
||||
start: dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
end: dayjs().endOf('month').format('YYYY-MM-DD'),
|
||||
label: 'this month',
|
||||
};
|
||||
}
|
||||
const day = dayFromWords(text);
|
||||
const label = day === today() ? 'today' : day;
|
||||
return { start: day, end: day, label };
|
||||
};
|
||||
|
||||
/* ── Domain guards ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Guards so a generic order/status intent refuses a question that is plainly
|
||||
* about another domain. Deliberately redundant with intent ordering: if someone
|
||||
* reorders the catalog later, these still hold.
|
||||
*/
|
||||
export const mentionsRiders = (text) => /\b(rider|riders|miler|milers)\b/i.test(text);
|
||||
export const mentionsTenants = (text) => /\b(tenant|tenants|client|clients)\b/i.test(text);
|
||||
export const mentionsFleet = (text) =>
|
||||
/\b(hub|hubs|vehicle|vehicles|tripsheet|tripsheets|exception|exceptions|partner|partners)\b/i.test(text);
|
||||
export const mentionsCustomers = (text) => /\b(customer|customers|recipient|recipients)\b/i.test(text);
|
||||
|
||||
/* ── Statuses and batches ─────────────────────────────────────────────────── */
|
||||
|
||||
/** An order-status group named in the question, using the Orders taxonomy. */
|
||||
export const statusFromWords = (text) => {
|
||||
const lower = text.toLowerCase();
|
||||
if (/\bcancel(led|ed|lation|lations)?\b/.test(lower)) return 'cancelled';
|
||||
if (/\bdeliver(ed|y|ies)?\b/.test(lower) && !/\bout for\b/.test(lower)) return 'delivered';
|
||||
if (/\bout for delivery|in transit|on the road\b/.test(lower)) return 'active';
|
||||
if (/\bassigned\b/.test(lower)) return 'assigned';
|
||||
if (/\bpending|unassigned|waiting\b/.test(lower)) return 'pending';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const batchFromWords = (text) => {
|
||||
const lower = text.toLowerCase();
|
||||
if (/\bmorning\b/.test(lower)) return 'morning';
|
||||
if (/\bafternoon\b/.test(lower)) return 'afternoon';
|
||||
if (/\bevening\b/.test(lower)) return 'evening';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isInStatusGroup = (booking, group) =>
|
||||
(ORDER_STATUS_GROUPS[group] || []).includes(String(booking.status || '').toLowerCase());
|
||||
|
||||
export const batchOf = (booking) => getRowBatchId({ orderdate: booking.createdat });
|
||||
|
||||
/* ── Money and tallies ────────────────────────────────────────────────────── */
|
||||
|
||||
export const bookingCharge = (booking) => Number(booking?.serviceoptions?.[0]?.estimatedprice) || 0;
|
||||
|
||||
export const isCancelled = (booking) => String(booking.status || '').toLowerCase() === 'cancelled';
|
||||
|
||||
/**
|
||||
* Estimated revenue. Sums the quoted price on non-cancelled rows — it is a
|
||||
* quote, not a settled amount, and every answer says "estimated" for that
|
||||
* reason.
|
||||
*/
|
||||
export const revenueOf = (rows) => rows.filter((row) => !isCancelled(row)).reduce((sum, row) => sum + bookingCharge(row), 0);
|
||||
|
||||
export const formatRupees = (value) =>
|
||||
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2 }).format(value || 0);
|
||||
|
||||
/** A prose tally of statuses, using the Orders taxonomy. */
|
||||
export const summarizeStatuses = (rows) => {
|
||||
const counts = {};
|
||||
rows.forEach((row) => {
|
||||
const group = groupForBookingStatus(row.status);
|
||||
const label = ORDER_STATUS_LABELS[group] || group;
|
||||
counts[label] = (counts[label] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts)
|
||||
.map(([label, value]) => `${value} ${label.toLowerCase()}`)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
/**
|
||||
* The same tallies as `summarizeStatuses`, shaped for the panel's metric grid.
|
||||
* Both read the same classification, so the sentence and the cards can never
|
||||
* disagree. An enum the group map has never seen still shows, rather than being
|
||||
* silently dropped from the totals.
|
||||
*/
|
||||
export const statusStats = (rows) => {
|
||||
const counts = {};
|
||||
rows.forEach((row) => {
|
||||
const group = groupForBookingStatus(row.status);
|
||||
counts[group] = (counts[group] || 0) + 1;
|
||||
});
|
||||
|
||||
const known = ORDER_STATUS_ORDER.filter((group) => counts[group]).map((group) => ({
|
||||
label: ORDER_STATUS_LABELS[group],
|
||||
value: counts[group],
|
||||
}));
|
||||
const unmapped = Object.keys(counts)
|
||||
.filter((group) => !ORDER_STATUS_ORDER.includes(group))
|
||||
.map((group) => ({ label: group, value: counts[group] }));
|
||||
|
||||
return [...known, ...unmapped];
|
||||
};
|
||||
|
||||
/** Count per distinct value of a field — the fleet intents all tally this way. */
|
||||
export const tallyBy = (rows, field) => {
|
||||
const counts = {};
|
||||
(rows || []).forEach((row) => {
|
||||
const key = row?.[field] || 'Unspecified';
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts).map(([label, value]) => ({ label: String(label), value }));
|
||||
};
|
||||
|
||||
/**
|
||||
* The best record whose name overlaps the phrase, in either direction — the
|
||||
* question may name a shorter or a longer form than the record holds. Prefers
|
||||
* the longest match, so "Acme" cannot beat "Acme Foods" when both exist.
|
||||
*/
|
||||
export const bestNameMatch = (needle, records, nameOf) => {
|
||||
const query = String(needle).toLowerCase().trim();
|
||||
if (query.length < 2) return null;
|
||||
const hits = (records || []).filter((record) => {
|
||||
const name = String(nameOf(record) || '').toLowerCase();
|
||||
return name.length > 1 && (name.includes(query) || query.includes(name));
|
||||
});
|
||||
if (!hits.length) return null;
|
||||
return hits.sort((a, b) => String(nameOf(b) || '').length - String(nameOf(a) || '').length)[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* A specific order referenced in the question.
|
||||
*
|
||||
* `strong` marks an unmistakable reference (`#1234`, `DM-…`); a bare run of
|
||||
* digits is weak, because it could equally be a year, a pincode or a quantity.
|
||||
* The distinction decides what a miss means: a strong id that is not found is
|
||||
* answered "I could not find it", a weak one falls through to broader intents
|
||||
* rather than hard-failing a question that was never about one order.
|
||||
*/
|
||||
export const orderIdFromWords = (text) => {
|
||||
const hash = text.match(/#\s*([A-Za-z0-9-]{3,})/);
|
||||
if (hash) return { id: hash[1], strong: true };
|
||||
|
||||
const code = text.match(/\bDM-[A-Za-z0-9-]+\b/i);
|
||||
if (code) return { id: code[0], strong: true };
|
||||
|
||||
/* Strip explicit dates first, so "order status on 12/08/2026" does not read
|
||||
the year as an order number. */
|
||||
const withoutDates = text.replace(/\b\d{1,4}[/-]\d{1,2}[/-]\d{2,4}\b/g, ' ');
|
||||
const bare = withoutDates.match(/\b(\d{4,})\b/);
|
||||
if (bare) return { id: bare[1], strong: false };
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** "today", or the date spelled out — for a headline that reads naturally
|
||||
* either way ("3 orders today" vs "3 orders on 20 Aug 2026"). */
|
||||
export const describeDay = (day) => (day === today() ? 'today' : dayjs(day).format('DD MMM YYYY'));
|
||||
|
||||
/** The first tenant whose name appears literally in the question. Bidirectional
|
||||
* matching (`bestNameMatch`) is for a name the operator already named on
|
||||
* purpose; this is for spotting one mentioned in passing ("orders for Acme
|
||||
* today"), so a plain substring check is the right, narrower tool. */
|
||||
export const resolveTenant = async (text) => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
const lower = String(text || '').toLowerCase();
|
||||
return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase())) || null;
|
||||
};
|
||||
|
||||
/* Trailing verbs/adverbs a greedy name capture sweeps up — "hub Chennai
|
||||
* status" must resolve to "Chennai", not "Chennai status". */
|
||||
const NAME_TAIL_WORDS =
|
||||
/\b(deliver(ed|y|ies)?|complete[d]?|assign(ed)?|do|did|does|has|have|is|are|was|were|today|yesterday|now|status|this|last|week|month|year)\b/gi;
|
||||
|
||||
const cleanEntityName = (raw) => {
|
||||
const cleaned = String(raw || '')
|
||||
.replace(NAME_TAIL_WORDS, ' ')
|
||||
.replace(/[?.,]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return cleaned.length >= 2 ? cleaned : null;
|
||||
};
|
||||
|
||||
/** The name following a keyword like "hub" or "vehicle" in a lookup question —
|
||||
* "status of hub Chennai" → "Chennai". */
|
||||
export const nameAfterKeyword = (text, keyword) => {
|
||||
const m = text.match(new RegExp(`\\b${keyword}\\s+(?:named\\s+|called\\s+)?([A-Za-z0-9][A-Za-z0-9 .'-]{1,40})`, 'i'));
|
||||
return m ? cleanEntityName(m[1]) : null;
|
||||
};
|
||||
|
||||
/** A tenant named after "for"/"tenant" — "orders for the Acme Foods today". */
|
||||
export const tenantNameCandidate = (text) => {
|
||||
const m = text.match(/\b(?:for|tenant)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9 &.'-]{1,40})/i);
|
||||
return m ? cleanEntityName(m[1]) : null;
|
||||
};
|
||||
|
||||
export { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus };
|
||||
Reference in New Issue
Block a user