updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View 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}` : ''}`
}
]
};
}
};

View 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
};
};

View 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);

View 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'
])
);

View 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);

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

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

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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 }] };
}
};

View 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);

View 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';

View 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';
};

View 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);

View 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
View 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
View 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 };