2311 lines
104 KiB
JavaScript
2311 lines
104 KiB
JavaScript
import dayjs from 'dayjs';
|
||
|
||
import {
|
||
getBookingsPage,
|
||
getHubs,
|
||
getVehicles,
|
||
getTripsheets,
|
||
getExceptions,
|
||
getAppUsers,
|
||
getAdminCustomers,
|
||
getAdminPricing,
|
||
getMilers,
|
||
getConsignments,
|
||
getPartners,
|
||
getCompetitorBranches,
|
||
getCarrierPricing,
|
||
getMilerSummary,
|
||
getMilerActivity,
|
||
trackConsignment,
|
||
getConsignmentLogs,
|
||
getAdminTenant,
|
||
getTenantLocations
|
||
} from 'pages/api/doormileApi';
|
||
import { getalltenants, getallridersummary } from 'pages/api/api';
|
||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
|
||
import { STATUS } from 'themes/dt/tokens';
|
||
// Only the read-only half of actions.js belongs here. Tenant resolution,
|
||
// payload building and execution live in the panel's submit handler — an
|
||
// intent must have no route to a write.
|
||
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
|
||
import { CREATE_ORDER_TRIGGER } from './orderActions';
|
||
import { ASSIGN_TRIGGER } from './assignActions';
|
||
import { REPEAT_TRIGGER } from './repeatRuns';
|
||
import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
|
||
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
|
||
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
|
||
|
||
// ==============================|| Doormile Bot — intent catalog ||============================== //
|
||
//
|
||
// Deterministic, not LLM-based (see the plan this was built from — a real
|
||
// LLM step needs a secret key held server-side, and this app has no backend
|
||
// of its own; the tradeoff accepted here is broader keyword/synonym coverage
|
||
// instead). Every answer comes from a real API call against the same
|
||
// functions the rest of the console uses, never a generated guess. `match`
|
||
// extracts params from raw text or returns null (this intent doesn't
|
||
// apply); `run` calls real data and returns { headline, detail,
|
||
// sourceCalls } — `sourceCalls` feeds <ChatToolCalls> so an operator can see
|
||
// exactly what was queried.
|
||
//
|
||
// `run` may also return null (matched the pattern but couldn't resolve
|
||
// something, e.g. an unrecognised tenant name) — the caller then tries the
|
||
// next intent in the list rather than answering with a guess.
|
||
//
|
||
// GET /admin/bookings has no server-side date/status/tenant filter — every
|
||
// intent bulk-fetches and filters client-side, the same pattern
|
||
// fetchDeliveries (api.js) already uses.
|
||
const BULK_PAGESIZE = 1000;
|
||
|
||
// ---- Typo tolerance --------------------------------------------------------
|
||
// A small Levenshtein-distance corrector run once, before any intent match,
|
||
// so a misspelled domain keyword ("riedrs", "vehcile") still routes to the
|
||
// right intent. Deliberately narrow: only whole alphabetic words of length
|
||
// >=5 are ever considered, and only against a fixed vocabulary of the same
|
||
// keywords the intents below key off — order IDs, tenant names, and short
|
||
// words are never touched, so this can't quietly rewrite something that was
|
||
// supposed to stay exact.
|
||
const levenshtein = (a, b) => {
|
||
const m = a.length;
|
||
const n = b.length;
|
||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||
for (let i = 0; i <= m; i += 1) dp[i][0] = i;
|
||
for (let j = 0; j <= n; j += 1) dp[0][j] = j;
|
||
for (let i = 1; i <= m; i += 1) {
|
||
for (let j = 1; j <= n; j += 1) {
|
||
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
|
||
}
|
||
}
|
||
return dp[m][n];
|
||
};
|
||
|
||
const KEYWORD_VOCAB = [
|
||
'order',
|
||
'orders',
|
||
'booking',
|
||
'bookings',
|
||
'rider',
|
||
'riders',
|
||
'tenant',
|
||
'tenants',
|
||
'hub',
|
||
'hubs',
|
||
'vehicle',
|
||
'vehicles',
|
||
'tripsheet',
|
||
'tripsheets',
|
||
'exception',
|
||
'exceptions',
|
||
'customer',
|
||
'customers',
|
||
'pricing',
|
||
'revenue',
|
||
'consignment',
|
||
'consignments',
|
||
'partner',
|
||
'partners',
|
||
'competitor',
|
||
'competitors',
|
||
'carrier',
|
||
'status',
|
||
'morning',
|
||
'afternoon',
|
||
'evening',
|
||
'pending',
|
||
'cancelled',
|
||
'delivered',
|
||
'active',
|
||
'today',
|
||
'yesterday',
|
||
'week',
|
||
'month',
|
||
'available'
|
||
];
|
||
|
||
// Words that form a NAMED ENTITY must never be "corrected". A tenant called
|
||
// "Partnerz" is one edit from "partners" and a rider called "Delivara" is two
|
||
// from "delivered"; rewriting either makes the name unresolvable, and
|
||
// orderQuery extracts tenant/rider names from the corrected text, so the
|
||
// failure is silent — the filter just never matches.
|
||
//
|
||
// The original comment here claimed order IDs and tenant names were never
|
||
// touched. Nothing enforced that; this does.
|
||
const ENTITY_NAME_SPAN = /\b(?:rider|tenant|hub|vehicle|customer|partner|named|called|for)\s+((?:[A-Za-z][A-Za-z0-9.'-]*\s*){1,4})/gi;
|
||
|
||
const protectedNameWords = (text) => {
|
||
const keep = new Set();
|
||
const re = new RegExp(ENTITY_NAME_SPAN.source, 'gi');
|
||
let m = re.exec(text);
|
||
while (m !== null) {
|
||
m[1]
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.forEach((w) => keep.add(w.toLowerCase()));
|
||
m = re.exec(text);
|
||
}
|
||
return keep;
|
||
};
|
||
|
||
const correctTypos = (text) => {
|
||
const keep = protectedNameWords(text);
|
||
return text
|
||
.split(/\b/)
|
||
.map((token) => {
|
||
const word = token.toLowerCase();
|
||
if (keep.has(word)) return token;
|
||
if (!/^[a-z]+$/.test(word) || word.length < 5 || KEYWORD_VOCAB.includes(word)) return token;
|
||
// >=6 chars allows distance 2, which is what a single adjacent-letter
|
||
// transposition ("riedrs" for "riders") costs in plain Levenshtein
|
||
// distance (two substitutions, not one) — a very common typo shape.
|
||
const maxDist = word.length >= 6 ? 2 : 1;
|
||
let best = null;
|
||
let bestDist = maxDist + 1;
|
||
KEYWORD_VOCAB.forEach((v) => {
|
||
if (Math.abs(v.length - word.length) > maxDist) return;
|
||
const dist = levenshtein(word, v);
|
||
if (dist < bestDist) {
|
||
bestDist = dist;
|
||
best = v;
|
||
}
|
||
});
|
||
return best && bestDist <= maxDist ? best : token;
|
||
})
|
||
.join('');
|
||
};
|
||
|
||
const TODAY = () => dayjs().format('YYYY-MM-DD');
|
||
|
||
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||
|
||
// Explicit calendar date — "12/08/2026" or "12-08-2026" (DD/MM/YYYY, matching
|
||
// this console's Indian-locale date convention elsewhere), or ISO
|
||
// "2026-08-12". Returns null when nothing explicit is found — callers fall
|
||
// back to relative-word parsing rather than guessing a date shape.
|
||
const explicitDateFromWords = (text) => {
|
||
const iso = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
|
||
if (iso) {
|
||
const d = dayjs(`${iso[1]}-${iso[2]}-${iso[3]}`);
|
||
return d.isValid() ? d.format('YYYY-MM-DD') : null;
|
||
}
|
||
const dmy = text.match(/\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b/);
|
||
if (dmy) {
|
||
const d = dayjs(`${dmy[3]}-${dmy[2].padStart(2, '0')}-${dmy[1].padStart(2, '0')}`);
|
||
return d.isValid() ? d.format('YYYY-MM-DD') : null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// "last Monday" / "on Monday" / bare "Monday" — the most recent day
|
||
// (including today) that falls on that weekday. Never resolves to a future
|
||
// date.
|
||
const weekdayFromWords = (text) => {
|
||
const lower = text.toLowerCase();
|
||
const found = WEEKDAYS.find((w) => lower.includes(w));
|
||
if (!found) return null;
|
||
const targetDow = WEEKDAYS.indexOf(found);
|
||
let d = dayjs();
|
||
for (let i = 0; i < 7; i += 1) {
|
||
if (d.day() === targetDow) return d.format('YYYY-MM-DD');
|
||
d = d.subtract(1, 'day');
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Broader than v1's today/yesterday-only: explicit dates, named weekdays,
|
||
// and a few more relative-date phrasings. Anything not recognised falls
|
||
// back to today — this stays a fixed, defensive vocabulary, not a general
|
||
// date-parsing library; guessing wrong on a date is worse than defaulting
|
||
// to today.
|
||
const dayFromWords = (text) => {
|
||
const explicit = explicitDateFromWords(text);
|
||
if (explicit) return explicit;
|
||
const weekday = weekdayFromWords(text);
|
||
if (weekday) return weekday;
|
||
if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||
return TODAY();
|
||
};
|
||
|
||
// Ranges: rolling "this week", calendar "last week", "this/last month", and
|
||
// an explicit "from <date> to <date>". Returns null when the text doesn't
|
||
// ask for a range, so callers can tell "today" and "this week" apart.
|
||
const rangeFromWords = (text) => {
|
||
if (/\bthis week\b|\bpast week\b|\blast 7 days\b|\blast seven days\b/i.test(text)) {
|
||
return { start: dayjs().subtract(6, 'day').format('YYYY-MM-DD'), end: TODAY(), label: 'this week' };
|
||
}
|
||
if (/\blast week\b/i.test(text)) {
|
||
const start = dayjs().subtract(1, 'week').startOf('week');
|
||
const end = dayjs().subtract(1, 'week').endOf('week');
|
||
return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD'), label: 'last week' };
|
||
}
|
||
if (/\bthis month\b/i.test(text)) {
|
||
return { start: dayjs().startOf('month').format('YYYY-MM-DD'), end: TODAY(), label: 'this month' };
|
||
}
|
||
if (/\blast month\b/i.test(text)) {
|
||
const start = dayjs().subtract(1, 'month').startOf('month');
|
||
const end = dayjs().subtract(1, 'month').endOf('month');
|
||
return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD'), label: 'last month' };
|
||
}
|
||
// A named calendar year. Requires a preposition so a bare 4-digit run (an
|
||
// order number) is never read as a year, and only accepts 19xx/20xx.
|
||
const year = text.match(/\b(?:in|during|for|of)\s+((?:19|20)\d{2})\b/i);
|
||
if (year) {
|
||
return { start: `${year[1]}-01-01`, end: `${year[1]}-12-31`, label: year[1] };
|
||
}
|
||
// "from X to Y" — only resolves when BOTH sides parse as a real date/
|
||
// weekday; otherwise this is probably "orders from Chennai to Mumbai" and
|
||
// must fall through untouched.
|
||
const fromTo = text.match(/\bfrom\b(.+?)\bto\b(.+)/i);
|
||
if (fromTo) {
|
||
const start = explicitDateFromWords(fromTo[1]) || weekdayFromWords(fromTo[1]);
|
||
const end = explicitDateFromWords(fromTo[2]) || weekdayFromWords(fromTo[2]);
|
||
if (start && end) {
|
||
const [rangeStart, rangeEnd] = end >= start ? [start, end] : [end, start];
|
||
return { start: rangeStart, end: rangeEnd, label: `${dayjs(rangeStart).format('DD MMM')} to ${dayjs(rangeEnd).format('DD MMM')}` };
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const describeDay = (day) => (day === TODAY() ? 'today' : dayjs(day).format('DD MMM YYYY'));
|
||
|
||
const batchFromWords = (text) => {
|
||
if (/\bmorning\b/i.test(text)) return 'morning';
|
||
if (/\bafternoon\b/i.test(text)) return 'afternoon';
|
||
if (/\bevening\b/i.test(text)) return 'evening';
|
||
return null;
|
||
};
|
||
|
||
// Wider synonym coverage than v1 — "in progress"/"on the way" for active,
|
||
// "done"/"completed" alongside "delivered", "declined"/"rejected" alongside
|
||
// "cancelled". Order matters: more specific phrases are checked before the
|
||
// broader "delivered" pattern so "undelivered" doesn't false-match it.
|
||
// Returns an ORDER STATUS GROUP key (utils/orderStatusGroups.js) — the same
|
||
// taxonomy the Orders page's tabs count with. It previously returned api.js's
|
||
// delivery-status buckets, which is the Deliveries page's rider-centric view,
|
||
// so "how many assigned orders" never agreed with the Assigned tab.
|
||
//
|
||
// Branch order matters: "not assigned"/"unassigned" must be caught by the
|
||
// pending branch before the assigned branch sees the word "assigned".
|
||
const statusFromWords = (text) => {
|
||
if (/\bpending\b|\bnot\s*assigned\b|\bunassigned\b|\bawaiting\b/i.test(text)) return 'pending';
|
||
// `cancel(led)?` did not match \"cancellation\" — the \b after \"cancel\" fails
|
||
// against the following 'l', so \"cancellation rate\" fell through to a plain
|
||
// week count.
|
||
if (/\bcancel(?:s|led|lation|lations)?\b|\bdeclined?\b|\brejected\b/i.test(text)) return 'cancelled';
|
||
if (/\bassigned\b|\baccept(ed)?\b/i.test(text)) return 'assigned';
|
||
if (/\bactive\b|\bin[- ]?transit\b|\bin\s*progress\b|\bon\s*the\s*way\b|\bout\s*for\s*delivery\b/i.test(text)) return 'active';
|
||
if (/\bundeliver/i.test(text)) return null;
|
||
if (/\bdeliver(ed)?\b|\bdone\b|\bcomplete[d]?\b/i.test(text)) return 'delivered';
|
||
return null;
|
||
};
|
||
|
||
// True only when the question actually NAMES a date. Distinguishing "named a
|
||
// date" from "defaulted to today" is what lets a state question ("how many are
|
||
// cancelled") mean right-now while a flow question ("how many orders today")
|
||
// still means today.
|
||
const mentionsAnyDate = (text) =>
|
||
explicitDateFromWords(text) !== null ||
|
||
weekdayFromWords(text) !== null ||
|
||
rangeFromWords(text) !== null ||
|
||
/\btoday\b|\byesterday\b/i.test(text);
|
||
|
||
// ---- Paginated booking source ---------------------------------------------
|
||
//
|
||
// Previously this was a single `getBookings(1, 1000)`. That is page ONE at the
|
||
// API's hard cap (express-console-api.md: pagesize default 500, cap 1000), and
|
||
// getBookings discards the envelope's `total`, so once an account passed 1000
|
||
// lifetime bookings EVERY count and sum in this file silently under-reported
|
||
// while the sourceCalls line next to it still read `status: 'complete'`. That
|
||
// is precisely the failure mode CLAUDE.md §3 forbids ("a wrong number from
|
||
// this bot is worse than no answer").
|
||
//
|
||
// Now: drain pages up to a budget, and when the budget is hit say so instead
|
||
// of presenting a partial scan as a total.
|
||
const MAX_PAGES = 12; // 12k rows — generous, but bounded so one question can't hammer the API
|
||
|
||
// Row order is not documented. When page 1 comes back newest-first we can stop
|
||
// as soon as a page ends older than the range; otherwise we scan 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 = (b, start, end) => {
|
||
const d = parseDoormileTimestamp(b.createdat);
|
||
if (!d.isValid()) return false;
|
||
const day = d.format('YYYY-MM-DD');
|
||
return day >= start && day <= end;
|
||
};
|
||
|
||
// Interim request cache. answerMultiPart runs several intents for one
|
||
// question, and comparisonIntent runs two ranges — each would otherwise
|
||
// re-drain the same pages. Keyed by page number with a short TTL.
|
||
//
|
||
// Deliberately small and local: this is NOT a caching layer to settle on. The
|
||
// real fix is routing these reads through TanStack Query like the rest of the
|
||
// console (CLAUDE.md §7), at which point this goes away.
|
||
const PAGE_CACHE_TTL_MS = 20000;
|
||
const pageCache = new Map();
|
||
|
||
const getBookingsPageCached = (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;
|
||
};
|
||
|
||
// Returns { rows, truncated, scanned, total } — NOT a bare array. Callers must
|
||
// surface `truncated`; see countPhrase/truncationNote/scanCall below.
|
||
const fetchBookingsInRange = async (start, end) => {
|
||
const firstPage = await getBookingsPageCached(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 page 1 already ends before the window opens → every later
|
||
// page is older still. 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) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const next = await getBookingsPageCached(page);
|
||
lastPageFetched = page;
|
||
if (!next.rows.length) {
|
||
stoppedEarly = true;
|
||
break;
|
||
}
|
||
collected.push(...next.rows);
|
||
stoppedEarly = pageEndsBeforeRange(next.rows);
|
||
}
|
||
|
||
return {
|
||
rows: collected.filter((b) => inRange(b, start, end)),
|
||
// Only truncated if we ran out of budget with pages still unread AND we
|
||
// didn't stop early because we'd already scanned past the window.
|
||
truncated: !stoppedEarly && pageCount > budget,
|
||
scanned: collected.length,
|
||
pagesFetched: lastPageFetched,
|
||
total
|
||
};
|
||
};
|
||
|
||
const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
|
||
|
||
// Full scan, no date window — for the 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.
|
||
// Exported for assignFlow.js, which resolves a typed order number against the
|
||
// real booking list. It reuses this rather than growing a second scanner with
|
||
// its own idea of pagination and truncation.
|
||
export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31');
|
||
|
||
// A count built on a truncated scan is a floor, not a total — say "at least".
|
||
const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`;
|
||
|
||
const truncationNote = (scan) =>
|
||
scan.truncated
|
||
? `\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
|
||
// `error` when truncated so the tool-call strip can't show a green "complete"
|
||
// beside a partial number.
|
||
const scanCall = (scan, note) => ({
|
||
name: 'getBookingsPage',
|
||
target: `/admin/bookings (${scan.pagesFetched} page${scan.pagesFetched === 1 ? '' : 's'} x ${BULK_PAGESIZE})`,
|
||
status: scan.truncated ? 'error' : 'complete',
|
||
errorMessage: scan.truncated ? `Scan capped at ${MAX_PAGES} pages; ${scan.total} bookings exist` : undefined,
|
||
stats: note
|
||
});
|
||
|
||
const summarizeStatuses = (rows) => {
|
||
const counts = {};
|
||
rows.forEach((b) => {
|
||
const s = ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] || groupForBookingStatus(b.status);
|
||
counts[s] = (counts[s] || 0) + 1;
|
||
});
|
||
return Object.entries(counts)
|
||
.map(([k, v]) => `${v} ${k}`)
|
||
.join(', ');
|
||
};
|
||
|
||
// Structured counterpart to summarizeStatuses — the SAME tallies, shaped for
|
||
// the AI panel's metric grid instead of a prose string. Both read the same
|
||
// groupForBookingStatus classification, so the sentence and the
|
||
// cards can never disagree, and a card can never show a number that didn't
|
||
// come from the rows the sourceCalls entry accounts for.
|
||
// One colour per order status group. `assigned` borrows the 'accepted' hex —
|
||
// STATUS is the raw palette and has no 'assigned' key; the group taxonomy and
|
||
// the colour palette are separate concerns and shouldn't be forced to match
|
||
// names.
|
||
const GROUP_COLOR = {
|
||
pending: STATUS.pending,
|
||
assigned: STATUS.accepted,
|
||
active: STATUS.active,
|
||
delivered: STATUS.delivered,
|
||
cancelled: STATUS.cancelled
|
||
};
|
||
|
||
const statusStats = (rows) => {
|
||
const counts = {};
|
||
rows.forEach((b) => {
|
||
const g = groupForBookingStatus(b.status);
|
||
counts[g] = (counts[g] || 0) + 1;
|
||
});
|
||
const ordered = ORDER_STATUS_ORDER.filter((g) => counts[g]).map((g) => ({
|
||
label: ORDER_STATUS_LABELS[g],
|
||
value: counts[g],
|
||
color: GROUP_COLOR[g]
|
||
}));
|
||
// A backend enum the group map has never seen still shows (neutral) rather
|
||
// than being dropped — an unmapped state should be visible, not silently
|
||
// absent from the totals.
|
||
const unmapped = Object.keys(counts)
|
||
.filter((g) => !ORDER_STATUS_ORDER.includes(g))
|
||
.map((g) => ({ label: g, value: counts[g], color: STATUS.muted }));
|
||
return [...ordered, ...unmapped];
|
||
};
|
||
|
||
// Generic "count per distinct value of `field`" — used by the fleet/ops
|
||
// intents below (hub type, vehicle type, exception severity, ...) so they
|
||
// don't each hand-roll the same tally loop.
|
||
const summarizeByField = (rows, field) => {
|
||
const counts = {};
|
||
rows.forEach((r) => {
|
||
const key = r?.[field] || 'unknown';
|
||
counts[key] = (counts[key] || 0) + 1;
|
||
});
|
||
return Object.entries(counts)
|
||
.map(([k, v]) => `${v} ${k}`)
|
||
.join(', ');
|
||
};
|
||
|
||
const resolveTenant = async (text) => {
|
||
const tenants = (await getalltenants()) || [];
|
||
const lower = text.toLowerCase();
|
||
return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase()));
|
||
};
|
||
|
||
const bookingLabel = (b) => b.bookingno || `#${b.bookingid}`;
|
||
|
||
// ---- Lateness --------------------------------------------------------------
|
||
//
|
||
// The promised delivery time. deliveries.js and Dispatch.js BOTH document this
|
||
// field as "stable, but it is the PROMISED DELIVERY slot" and both rejected it
|
||
// — for BATCH BUCKETING, because a promised ETA is not the wave an order
|
||
// belongs to. That reasoning doesn't carry over here: an SLA promise that
|
||
// never gets re-stamped is exactly the right baseline to measure lateness
|
||
// against. (`assigntime` would be useless here for the same reason it was
|
||
// useless there — api.js maps it to the last-modified column.)
|
||
//
|
||
// ⚠ `serviceoptions` is NOT documented in express-console-api.md; its shape is
|
||
// inferred from fetchDeliveries. Hence the coverage guard in the intent below:
|
||
// if no row carries an ETA we say we can't tell, rather than reporting a
|
||
// reassuring "0 delayed".
|
||
const etaOf = (b) => b?.serviceoptions?.[0]?.estimateddeliveryat || null;
|
||
|
||
const AT_RISK_MINUTES = 60;
|
||
|
||
// 'closed' — delivered or cancelled; lateness is not a live concern
|
||
// 'no-eta' — open, but nothing to measure against
|
||
// 'late' — open and past its promised time
|
||
// 'at-risk' — open and due within the hour
|
||
// 'on-time' — open and comfortably ahead
|
||
const delayState = (b, now) => {
|
||
const group = groupForBookingStatus(b.status);
|
||
if (group === 'delivered' || group === 'cancelled') return 'closed';
|
||
const raw = etaOf(b);
|
||
if (!raw) return 'no-eta';
|
||
const eta = parseDoormileTimestamp(raw);
|
||
if (!eta.isValid()) return 'no-eta';
|
||
const minutes = eta.diff(now, 'minute');
|
||
if (minutes < 0) return 'late';
|
||
if (minutes <= AT_RISK_MINUTES) return 'at-risk';
|
||
return 'on-time';
|
||
};
|
||
|
||
const formatLateness = (b, now) => {
|
||
const mins = now.diff(parseDoormileTimestamp(etaOf(b)), 'minute');
|
||
if (mins < 60) return `${mins}m late`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return `${hours}h ${mins % 60}m late`;
|
||
return `${Math.floor(hours / 24)}d late`;
|
||
};
|
||
|
||
// Late orders carry no hub of their own — a booking has no hubid/hubname field
|
||
// at all (verified against fetchDeliveries' mapping). The only route to a hub
|
||
// is the assigned rider: booking.assignedmileruserid matches a miler's
|
||
// `userid`, and GET /admin/milers/summary is confirmed live to carry
|
||
// hubname per rider. Unassigned orders genuinely have no hub and are reported
|
||
// as their own bucket rather than being dropped or guessed at.
|
||
const hubBreakdownForLate = async (late) => {
|
||
if (!late.length) return null;
|
||
const milers = await getMilerSummary().catch(() => null);
|
||
if (!milers) return null;
|
||
const list = Array.isArray(milers) ? milers : [milers];
|
||
const hubByUser = new Map(list.map((m) => [m.userid, m.hubname]).filter(([userid]) => userid != null));
|
||
const counts = {};
|
||
late.forEach((b) => {
|
||
const hub = b.assignedmileruserid ? hubByUser.get(b.assignedmileruserid) || 'Unknown hub' : 'Unassigned';
|
||
counts[hub] = (counts[hub] || 0) + 1;
|
||
});
|
||
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
||
};
|
||
|
||
// Guard so the delay question can't be swallowed by hubStatus ("which HUBS are
|
||
// experiencing delays" contains "hubs"), statusBreakdown or totalOrders. Same
|
||
// belt-and-braces rule as mentionsRiders: delayedOrders is ordered ahead of all
|
||
// three AND they each refuse explicitly, so a later reorder can't regress it.
|
||
// Trailing verbs/adverbs that get swept up by a greedy name capture —
|
||
// "rider Suresh deliver today" must resolve to "Suresh", not "Suresh deliver".
|
||
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;
|
||
};
|
||
|
||
const riderNameFromWords = (text) => {
|
||
const m = text.match(/\brider\s+(?:named\s+|called\s+)?([A-Za-z][A-Za-z0-9 .'-]{1,40})/i);
|
||
return m ? cleanEntityName(m[1]) : null;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
// "top 5 tenants by orders", "orders by hub" — a ranking request rather than a
|
||
// single count.
|
||
const rankingFromWords = (text) => {
|
||
const top = text.match(/\btop\s+(\d{1,2})\b/i);
|
||
const by = text.match(/\bby\s+(tenant|hub|rider|status|batch)s?\b/i);
|
||
if (!top && !by) return null;
|
||
let dimension = by ? by[1].toLowerCase() : null;
|
||
if (!dimension) {
|
||
if (/\btenants?\b/i.test(text)) dimension = 'tenant';
|
||
else if (/\briders?\b/i.test(text)) dimension = 'rider';
|
||
else if (/\bhubs?\b/i.test(text)) dimension = 'hub';
|
||
}
|
||
if (!dimension) return null;
|
||
return { groupBy: dimension, limit: top ? Math.min(Number(top[1]), 25) : 5 };
|
||
};
|
||
|
||
// Bidirectional, case-insensitive containment — the question may name a
|
||
// shorter form of the record ("acme foods" vs "Acme Foods Pvt Ltd") or a
|
||
// longer one. Longest match wins so "Acme" doesn't beat "Acme Foods" when
|
||
// both exist.
|
||
const bestNameMatch = (needle, records, nameOf) => {
|
||
const q = String(needle).toLowerCase();
|
||
const hits = records.filter((r) => {
|
||
const name = String(nameOf(r) || '').toLowerCase();
|
||
return name.length > 1 && (name.includes(q) || q.includes(name));
|
||
});
|
||
if (!hits.length) return null;
|
||
return hits.sort((a, b) => String(nameOf(b) || '').length - String(nameOf(a) || '').length)[0];
|
||
};
|
||
|
||
// ---- L4 analytics helpers --------------------------------------------------
|
||
|
||
// Percentage with one decimal, but only when the denominator is big enough to
|
||
// mean anything. "100% cancelled" off two orders is noise, not a rate.
|
||
const rateOf = (part, whole) => (whole > 0 ? `${((part / whole) * 100).toFixed(whole < 20 ? 0 : 1)}%` : '—');
|
||
|
||
// Buckets rows by day (for a range) or by hour (for a single day), so a trend
|
||
// is answered from the same scan every other count uses.
|
||
const trendBuckets = (rows, byHour) => {
|
||
const counts = new Map();
|
||
rows.forEach((b) => {
|
||
const d = parseDoormileTimestamp(b.createdat);
|
||
if (!d.isValid()) return;
|
||
const key = byHour ? d.format('HH:00') : d.format('DD MMM');
|
||
counts.set(key, (counts.get(key) || 0) + 1);
|
||
});
|
||
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||
};
|
||
|
||
// A text bar keeps a trend readable in a 428px panel without a chart library.
|
||
const sparkBar = (n, max) => '█'.repeat(Math.max(1, Math.round((n / Math.max(max, 1)) * 12)));
|
||
|
||
const TREND_TRIGGER =
|
||
/\btrend\b|\bper\s+day\b|\bper\s+hour\b|\bby\s+day\b|\bby\s+hour\b|\bdaily\b|\bhourly\b|\bover\s+time\b|\bbreakdown\s+by\s+(?:day|hour)\b/i;
|
||
const RATE_TRIGGER = /\brate\b|\bpercentage\b|\bpercent\b|\b%\b|\bratio\b/i;
|
||
|
||
const mentionsDelay = (text) =>
|
||
/\bdelay(ed|s|ing)?\b|\blate\b|\boverdue\b|\bbehind\s+schedule\b|\brunning\s+late\b|\bsla\b|\bbreach(ed|es)?\b|\bat\s*risk\b/i.test(text);
|
||
|
||
// Sum across ALL service options. Reading only serviceoptions[0] under-counted
|
||
// any booking carrying more than one priced option.
|
||
const bookingCharge = (b) =>
|
||
(Array.isArray(b.serviceoptions) ? b.serviceoptions : []).reduce((sum, o) => sum + (Number(o?.estimatedprice) || 0), 0);
|
||
|
||
// Revenue excludes cancelled bookings — a cancelled order is not money earned,
|
||
// and counting it inflated every "total revenue" answer.
|
||
const isCancelled = (b) => groupForBookingStatus(b.status) === 'cancelled';
|
||
const revenueOf = (rows) => rows.filter((b) => !isCancelled(b)).reduce((sum, b) => sum + bookingCharge(b), 0);
|
||
|
||
const formatRupees = (n) => new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2 }).format(n || 0);
|
||
|
||
// A specific order id/number mentioned in the question — "#1234", "DM-xxx",
|
||
// or a bare number 4+ digits long (short numbers are too likely to be part of
|
||
// an unrelated word/date to treat as an order id).
|
||
// Returns { id, strong } or null. `strong` marks an unmistakable order
|
||
// 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
|
||
// happens on a miss: a strong id that isn't found is answered "I couldn't find
|
||
// it", a weak one falls through to the broader intents rather than hard-failing
|
||
// a question that was never about one specific order.
|
||
const orderIdFromWords = (text) => {
|
||
const hash = text.match(/#\s*([A-Za-z0-9-]{3,})/);
|
||
if (hash) return { id: hash[1], strong: true };
|
||
const dmCode = text.match(/\bDM-[A-Za-z0-9-]+\b/i);
|
||
if (dmCode) return { id: dmCode[0], strong: true };
|
||
// Strip explicit dates first so "order status on 12/08/2026" doesn't read
|
||
// 2026 as an order number.
|
||
const withoutDates = text.replace(/\b\d{1,2}[/-]\d{1,2}[/-]\d{4}\b/g, ' ').replace(/\b\d{4}-\d{2}-\d{2}\b/g, ' ');
|
||
const bareDigits = withoutDates.match(/\b\d{4,}\b/);
|
||
if (bareDigits) return { id: bareDigits[0], strong: false };
|
||
return null;
|
||
};
|
||
|
||
// Named-entity lookup helper for riderLookup/hubLookup/vehicleLookup below —
|
||
// pulls the word(s) right after "rider"/"hub"/"vehicle" (optionally preceded
|
||
// by "named"/"called"/"number"/"no.") as the thing to search for. Alnum only
|
||
// (vehicle numbers mix letters and digits), stops at a trailing "?" or a
|
||
// trailing status word.
|
||
const nameAfterKeyword = (text, keyword) => {
|
||
const re = new RegExp(
|
||
`\\b${keyword}\\b\\s+(?:named\\s+|called\\s+|number\\s+|no\\.?\\s+)?([a-z0-9][a-z0-9 .-]{1,40}?)(?:\\s*\\?|\\s+(?:today|now|status)\\b|$)`,
|
||
'i'
|
||
);
|
||
const m = text.match(re);
|
||
return m ? m[1].trim() : null;
|
||
};
|
||
|
||
// Requires one of these explicit phrasings before treating a rider/hub/
|
||
// vehicle question as a specific-entity lookup rather than an aggregate
|
||
// count — "how many riders are active" must NOT be swallowed by
|
||
// riderLookup just because it contains the word "rider".
|
||
const LOOKUP_TRIGGER = /\b(?:find|where\s+is|status\s+of|search(?:\s+for)?|lookup)\b/i;
|
||
|
||
// Questions ABOUT Doormile or about the assistant itself, rather than about
|
||
// the data. Kept tight: "how many doormile orders today" mentions the name but
|
||
// is an orders question, and must not be caught here.
|
||
// `['’]s|s` covers "what's", "what’s" and the apostrophe-less "whats" people
|
||
// actually type.
|
||
const ABOUT_TRIGGER =
|
||
/\b(?:what|who)(?:['’]s|s|\s+is|\s+are)\s+doormile\b|\b(?:tell\s+me\s+)?about\s+doormile\b|\bdoormile\s*\.\s*com\b|\bwho\s+are\s+you\b|\bwhat\s+(?:can|do)\s+you\s+(?:do|answer|help)\b|\bwhat\s+are\s+you\b/i;
|
||
|
||
// Guards against the class of bug found live: "how many riders are active
|
||
// today" contains the word "active", which is ALSO a valid order status
|
||
// (in-transit) — statusBreakdown's match used to fire on that word alone and
|
||
// never returns null on a match (it always finds *some* count, even 0), so
|
||
// it never yielded to riderCounts and every rider question silently called
|
||
// getBookings instead of getallridersummary. Fixed two ways, deliberately
|
||
// redundant: riderCounts/tenantList are ordered ahead of the generic
|
||
// intents below (INTENTS is checked in order, first match wins), AND the
|
||
// generic intents explicitly refuse to match when "rider" is mentioned, so
|
||
// the bug can't come back just because someone reorders the array later.
|
||
const mentionsRiders = (text) => /\brider(s)?\b/i.test(text);
|
||
|
||
// "orders today vs yesterday" / "revenue this week compared to last week" —
|
||
// a narrow, explicit trigger phrase so this can't misfire on plain aggregate
|
||
// questions.
|
||
const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
|
||
|
||
// "orders and revenue today" — segments a multi-part question so each half
|
||
// can be matched independently through the normal INTENTS catalog.
|
||
const MULTI_SPLIT = /\band\b|,|\+|&/i;
|
||
|
||
const INTENTS = [
|
||
{
|
||
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
|
||
// contains "orders", so createBulkOrders and createOrder would otherwise
|
||
// claim it and open a blank create instead of recalling the run.
|
||
id: 'repeatRun',
|
||
label: 'Repeat a past day’s orders — e.g. "repeat yesterday’s orders"',
|
||
match: (text) => (REPEAT_TRIGGER.test(text) ? {} : null),
|
||
run: async () => ({
|
||
headline: 'Let’s repeat a previous run.',
|
||
form: { kind: 'repeatRun', status: 'open' },
|
||
sourceCalls: []
|
||
})
|
||
},
|
||
{
|
||
// Ordered FIRST, ahead of createOrder: "create multiple orders" also
|
||
// matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger
|
||
// has to get first refusal or every bulk request opens the single form.
|
||
id: 'createBulkOrders',
|
||
label: 'Create several orders from a sheet or a paste — e.g. "bulk upload orders"',
|
||
match: (text) => (CREATE_BULK_TRIGGER.test(text) ? {} : null),
|
||
run: async () => ({
|
||
headline: 'Upload a sheet or paste your rows and I’ll create them.',
|
||
form: { kind: 'createBulkOrders', status: 'open' },
|
||
sourceCalls: []
|
||
})
|
||
},
|
||
{
|
||
// Ordered FIRST, above createCustomer: "create an order" contains
|
||
// "order", which orderLookup / orderQuery / totalOrders all match on.
|
||
// Like createCustomer this NEVER mutates — the panel intercepts this and
|
||
// starts a CONVERSATION (orderFlow.js); the mutation fires only when the
|
||
// operator presses Create on the priced confirmation at the end.
|
||
id: 'createOrder',
|
||
label: 'Create an order — e.g. "create an order"',
|
||
match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null),
|
||
run: async () => ({
|
||
headline: 'Let’s build the order.',
|
||
form: { kind: 'createOrder', status: 'open' },
|
||
sourceCalls: []
|
||
})
|
||
},
|
||
{
|
||
// ---- The only write-capable intent -------------------------------------
|
||
//
|
||
// Ordered FIRST: "create a customer …" contains the word "customer"
|
||
// (customerCount matches on it) and usually a 10-digit phone number
|
||
// (which orderIdFromWords would read as a weak order id). Neither may get
|
||
// the question.
|
||
//
|
||
// This intent NEVER mutates. It returns a proposal — the exact payload it
|
||
// would submit — and the panel does not send anything until the operator
|
||
// presses Create. See actions.js.
|
||
id: 'createCustomer',
|
||
label: 'Create a customer — e.g. "create a customer"',
|
||
match: (text) => (CREATE_CUSTOMER_TRIGGER.test(text) ? { text } : null),
|
||
run: async ({ text }) => {
|
||
// Opens a FORM rather than collecting fields conversationally.
|
||
//
|
||
// The conversational version shipped and immediately failed in use: a
|
||
// reply of just "8494948494" matches no intent, so answerQuestion routed
|
||
// it to the "I can't answer that yet" fallback and the number was lost.
|
||
// Threading a partial draft through `context` did not help, because the
|
||
// router selects an intent by matching TEXT and a bare phone number
|
||
// matches nothing. Real inputs remove the parsing step altogether.
|
||
//
|
||
// Still no mutation here: this returns the form's initial values, and
|
||
// the panel submits only when the operator presses Create.
|
||
const parsed = parseCustomerDraft(text);
|
||
return {
|
||
headline: 'Fill this in and I’ll create the customer.',
|
||
form: {
|
||
kind: 'createCustomer',
|
||
status: 'open',
|
||
initial: parsed
|
||
},
|
||
sourceCalls: []
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// Ordered ahead of orderLookup: "assign a rider to DM-BK-…" names an order
|
||
// number, and orderLookup would otherwise claim it and answer with the
|
||
// order's details instead of assigning anything.
|
||
id: 'assignRider',
|
||
label: 'Assign a rider to an order — e.g. "assign a rider to DM-BK-0D915D43-33705"',
|
||
match: (text) => (ASSIGN_TRIGGER.test(text) ? { ref: orderIdFromWords(text) } : null),
|
||
run: async ({ ref }) => {
|
||
// No order named — the flow will ask for one.
|
||
if (!ref?.strong) {
|
||
return { headline: 'Which order should I assign?', form: { kind: 'assignRider', status: 'open' }, sourceCalls: [] };
|
||
}
|
||
const scan = await scanBookings();
|
||
const needle = String(ref.id).toLowerCase();
|
||
const found = scan.rows.find((b) => String(b.bookingno || '').toLowerCase() === needle || String(b.bookingid) === ref.id);
|
||
if (!found) {
|
||
return {
|
||
headline: `I couldn't find order ${ref.id}.`,
|
||
detail: truncationNote(scan).trim() || 'Check the order number and try again.',
|
||
sourceCalls: [scanCall(scan, `no match for "${ref.id}"`)]
|
||
};
|
||
}
|
||
return {
|
||
headline: `Assigning ${bookingLabel(found)}.`,
|
||
form: { kind: 'assignRider', status: 'open', booking: found },
|
||
sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'orderLookup',
|
||
label: 'Everything about one order — e.g. "DM-BK-0D915D43-33705" or "status of order #1234"',
|
||
match: (text) => {
|
||
const ref = orderIdFromWords(text);
|
||
if (!ref) return null;
|
||
// A STRONG reference (DM-…, #1234) is the whole question — an operator
|
||
// pasting a booking number shouldn't have to wrap a sentence around it.
|
||
// A WEAK one (bare digits) still needs an order/booking/status/where
|
||
// word, or a stray "42" would be read as an order id.
|
||
if (!ref.strong && !/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
|
||
return { orderId: ref.id, strong: ref.strong };
|
||
},
|
||
run: async ({ orderId, strong }) => {
|
||
const scan = await scanBookings();
|
||
const needle = orderId.toLowerCase();
|
||
const found = scan.rows.find(
|
||
(b) =>
|
||
String(b.bookingno || '').toLowerCase() === needle ||
|
||
String(b.bookingid) === orderId ||
|
||
String(b.bookingno || '')
|
||
.toLowerCase()
|
||
.includes(needle)
|
||
);
|
||
if (!found) {
|
||
// A weak reference (bare digits) was probably never an order id —
|
||
// let the broader intents have the question. A strong one (#1234,
|
||
// DM-...) unmistakably WAS, so say it wasn't found rather than
|
||
// falling through and answering something else entirely.
|
||
if (!strong) return null;
|
||
return {
|
||
headline: `I couldn't find order ${orderId}.`,
|
||
detail: scan.truncated
|
||
? `Searched the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
|
||
'en-IN'
|
||
)} bookings — it may exist further back than I can scan.`
|
||
: `Searched all ${scan.scanned.toLocaleString('en-IN')} bookings. Check the order number and try again.`,
|
||
sourceCalls: [scanCall(scan, `no match for "${orderId}"`)]
|
||
};
|
||
}
|
||
// Same taxonomy the Orders page shows, so a per-order answer and the
|
||
// tab that order sits under can't disagree.
|
||
const status = ORDER_STATUS_LABELS[groupForBookingStatus(found.status)] || found.status;
|
||
|
||
// Resolve the rider to a NAME (the booking only carries an id), and pull
|
||
// the tracking trail. Both are enrichment: either failing degrades that
|
||
// one line rather than the whole answer.
|
||
// A booking carries `appcustomerid`, never the recipient's name — so the
|
||
// customer store is read to turn it into one. All three are enrichment:
|
||
// any of them failing costs that one line, not the whole answer.
|
||
// GET /admin/bookings/:id/track is deliberately NOT called. Its response
|
||
// shape was never confirmed (express-console-api.md lists it as
|
||
// written-but-unproven), so it contributed a "Tracking" line nobody could
|
||
// rely on and an audit entry that reported an error on every order that
|
||
// simply has no trail yet. Removed on explicit direction — don't add it
|
||
// back without a confirmed response shape.
|
||
const [milers, customers] = await Promise.all([
|
||
found.assignedmileruserid ? getMilers().catch(() => null) : Promise.resolve(null),
|
||
found.appcustomerid ? getAdminCustomers().catch(() => null) : Promise.resolve(null)
|
||
]);
|
||
const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null;
|
||
const customer = customers ? customers.find((c) => (c.appcustomerid ?? c.id) === found.appcustomerid) : null;
|
||
|
||
const extraCalls = [];
|
||
if (milers) {
|
||
extraCalls.push({
|
||
name: 'getMilers',
|
||
target: '/admin/milers',
|
||
status: 'complete',
|
||
stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id'
|
||
});
|
||
}
|
||
if (customers) {
|
||
extraCalls.push({
|
||
name: 'getAdminCustomers',
|
||
target: '/admin/customers',
|
||
status: 'complete',
|
||
stats: customer ? `resolved ${customer.name || customer.phone || `#${found.appcustomerid}`}` : 'no match for customer id'
|
||
});
|
||
}
|
||
|
||
const riderLine = found.assignedmileruserid
|
||
? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${
|
||
rider?.phone ? ` · ${rider.phone}` : ''
|
||
}`
|
||
: 'Not yet assigned to a rider.';
|
||
|
||
const service = found.serviceoptions?.[0];
|
||
const parcels = found.parcels || [];
|
||
const addr = (a, pin, city) => [a, city, pin].filter(Boolean).join(', ');
|
||
|
||
// Every field that is actually on the record, and nothing that isn't — a
|
||
// row is omitted rather than rendered as "—", so a blank never reads as
|
||
// "we checked and it's empty" when it means "this booking has no such
|
||
// field at all".
|
||
const items = [
|
||
{ label: 'Status', meta: `${status}${found.status && found.status !== status ? ` (${found.status})` : ''}` },
|
||
{ label: 'Rider', meta: riderLine.replace(/^Rider: /, '') },
|
||
customer || found.appcustomerid
|
||
? {
|
||
label: 'Customer',
|
||
meta: customer
|
||
? [customer.name || [customer.firstname, customer.lastname].filter(Boolean).join(' '), customer.phone]
|
||
.filter(Boolean)
|
||
.join(' · ')
|
||
: `#${found.appcustomerid} (name unavailable)`
|
||
}
|
||
: null,
|
||
found.pickupaddress ? { label: 'Pickup', meta: addr(found.pickupaddress, found.pickuppincode) } : null,
|
||
found.deliveryaddress ? { label: 'Drop', meta: addr(found.deliveryaddress, found.deliverypincode, found.deliverycity) } : null,
|
||
service
|
||
? {
|
||
label: 'Service',
|
||
meta: [service.servicetype, service.estimatedprice != null ? `₹${Number(service.estimatedprice).toFixed(2)}` : null]
|
||
.filter(Boolean)
|
||
.join(' · ')
|
||
}
|
||
: null,
|
||
parcels.length
|
||
? {
|
||
label: `Parcel${parcels.length === 1 ? '' : `s (${parcels.length})`}`,
|
||
meta: parcels
|
||
.slice(0, 3)
|
||
.map((p) => [p.itemcategory, p.itemdescription].filter(Boolean).join(' · '))
|
||
.join(' | ')
|
||
}
|
||
: null,
|
||
found.createdat ? { label: 'Created', meta: parseDoormileTimestamp(found.createdat).format('DD MMM YYYY, hh:mm A') } : null,
|
||
found.updatedat ? { label: 'Last updated', meta: parseDoormileTimestamp(found.updatedat).format('DD MMM YYYY, hh:mm A') } : null,
|
||
etaOf(found) ? { label: 'Promised by', meta: parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A') } : null,
|
||
service?.sladueat ? { label: 'SLA due', meta: parseDoormileTimestamp(service.sladueat).format('DD MMM, hh:mm A') } : null,
|
||
found.consignmentid ? { label: 'Consignment', meta: `#${found.consignmentid}` } : null,
|
||
found.bookingsource ? { label: 'Source', meta: found.bookingsource } : null,
|
||
found.notes ? { label: 'Notes', meta: found.notes } : null
|
||
].filter(Boolean);
|
||
|
||
return {
|
||
headline: `Order ${bookingLabel(found)} is ${status}.`,
|
||
list: { title: 'The full record', numbered: false, items },
|
||
// Lateness is the one thing worth saying before the rows are read.
|
||
detail:
|
||
delayState(found, dayjs()) === 'late'
|
||
? `Past its promised time — it was due ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}.`
|
||
: undefined,
|
||
sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// Ahead of riderCounts so "where is rider Kumar" resolves to a specific
|
||
// rider instead of the aggregate count.
|
||
id: 'riderLookup',
|
||
label: 'Look up a specific rider — e.g. "where is rider Kumar"',
|
||
match: (text) => {
|
||
if (!mentionsRiders(text) || !LOOKUP_TRIGGER.test(text)) return null;
|
||
const name = nameAfterKeyword(text, 'rider');
|
||
return name ? { name } : null;
|
||
},
|
||
run: async ({ name }) => {
|
||
const riders = (await getMilers()) || [];
|
||
const needle = name.toLowerCase();
|
||
const found = riders.find((r) =>
|
||
String(r.displayname || r.name || '')
|
||
.toLowerCase()
|
||
.includes(needle)
|
||
);
|
||
if (!found) return null;
|
||
return {
|
||
// Field names matter here: a miler has NO `status`, `phonenumber` or
|
||
// `vehicletype` field. The real ones are `availabilitystatus`,
|
||
// `phone` and `defaultvehicletype` (api.js documents the confirmed
|
||
// live shape). Reading the wrong names meant this answer always said
|
||
// "status unknown" and never showed a phone or vehicle.
|
||
headline: `${found.displayname || found.name} — ${found.availabilitystatus || 'availability unknown'}.`,
|
||
detail:
|
||
[
|
||
found.phone ? `Phone: ${found.phone}` : null,
|
||
found.defaultvehicletype ? `Vehicle: ${found.defaultvehicletype}` : null,
|
||
found.hubname ? `Hub: ${found.hubname}` : null
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n') || undefined,
|
||
sourceCalls: [{ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: `matched "${name}"` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// ---- L3: what has this rider actually done ----------------------------
|
||
//
|
||
// orderQuery can already count a named rider's ORDERS, but the rider's own
|
||
// record carries what the bookings feed cannot: accepted vs rejected,
|
||
// distance covered, and current duty state. GET /admin/milers/summary is
|
||
// the confirmed-live shape for that (api.js documents the fields).
|
||
id: 'riderActivity',
|
||
label: 'A rider’s own numbers — e.g. "how is rider Kumar doing today"',
|
||
match: (text) => {
|
||
if (!mentionsRiders(text)) return null;
|
||
if (
|
||
!/\bperformance\b|\bactivity\b|\bdoing\b|\bstats\b|\bhow\s+is\b|\bhow\s+has\b|\bcompleted\b|\brejected\b|\baccepted\b|\bkms?\b|\bdistance\b/i.test(
|
||
text
|
||
)
|
||
)
|
||
return null;
|
||
const name = riderNameFromWords(text);
|
||
if (!name) return null;
|
||
const range = rangeFromWords(text);
|
||
if (range) return { name, start: range.start, end: range.end, label: range.label };
|
||
const day = dayFromWords(text);
|
||
return { name, start: day, end: day, label: describeDay(day) };
|
||
},
|
||
run: async ({ name, start, end, label }) => {
|
||
const summary = (await getMilerSummary(undefined, start, end)) || [];
|
||
const list = Array.isArray(summary) ? summary : [summary];
|
||
const rider = bestNameMatch(name, list, (m) => m.displayname || m.name);
|
||
if (!rider) return null;
|
||
|
||
// The activity feed is optional colour — a failure degrades that one
|
||
// line rather than the whole answer.
|
||
const activity = rider.milerprofileid ? await getMilerActivity(rider.milerprofileid, start, end).catch(() => null) : null;
|
||
|
||
const assigned = Number(rider.assigned) || 0;
|
||
const stats = [
|
||
{ label: 'Assigned', value: assigned, color: STATUS.accepted },
|
||
{ label: 'Completed', value: Number(rider.completed) || Number(rider.delivered) || 0, color: STATUS.delivered },
|
||
{ label: 'Rejected', value: Number(rider.rejected) || 0, color: STATUS.cancelled },
|
||
{ label: 'Cancelled', value: Number(rider.cancelled) || 0, color: STATUS.muted }
|
||
];
|
||
|
||
return {
|
||
headline: `${rider.displayname || rider.name} — ${rider.availabilitystatus || 'status unknown'} ${label}.`,
|
||
metric: { value: Number(rider.completed) || Number(rider.delivered) || 0, label: `Completed ${label}` },
|
||
stats,
|
||
detail: [
|
||
assigned ? `Acceptance ${rateOf(assigned - (Number(rider.rejected) || 0), assigned)} of ${assigned} assigned.` : null,
|
||
rider.riderkms ? `${rider.riderkms} km covered.` : null,
|
||
rider.hubname ? `Hub: ${rider.hubname}` : null,
|
||
rider.onduty != null ? `On duty: ${rider.onduty ? 'yes' : 'no'}` : null,
|
||
Array.isArray(activity) && activity.length ? `${activity.length} activity events in range.` : null
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n'),
|
||
sourceCalls: [
|
||
{ name: 'getMilerSummary', target: '/admin/milers/summary', status: 'complete', stats: `matched ${rider.displayname || name}` },
|
||
activity
|
||
? {
|
||
name: 'getMilerActivity',
|
||
target: `/admin/milers/${rider.milerprofileid}/activity`,
|
||
status: 'complete',
|
||
stats: `${activity.length} events`
|
||
}
|
||
: null
|
||
].filter(Boolean)
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// ---- L3: follow a parcel ----------------------------------------------
|
||
id: 'parcelTrack',
|
||
label: 'Track a parcel — e.g. "track consignment DM-CN-123"',
|
||
match: (text) => {
|
||
if (!/\btrack\b|\bconsignment\b|\bparcel\b|\bshipment\b/i.test(text)) return null;
|
||
const code = text.match(/\b([A-Z]{2,}-[A-Za-z0-9-]+)\b/) || text.match(/#\s*([A-Za-z0-9-]{3,})/);
|
||
if (!code) return null;
|
||
return { trackingno: code[1] };
|
||
},
|
||
run: async ({ trackingno }) => {
|
||
const found = await trackConsignment(trackingno).catch(() => null);
|
||
if (!found) {
|
||
return {
|
||
headline: `I couldn’t find a consignment for ${trackingno}.`,
|
||
detail: 'Check the tracking number — it looks like a code but nothing matched.',
|
||
sourceCalls: [
|
||
{ name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'error', errorMessage: 'No match' }
|
||
]
|
||
};
|
||
}
|
||
const id = found.consignmentid ?? found.id;
|
||
const logs = id ? await getConsignmentLogs(id).catch(() => null) : null;
|
||
const events = Array.isArray(logs) ? logs : [];
|
||
return {
|
||
headline: `${trackingno} — ${found.status || 'status unknown'}.`,
|
||
detail: [found.origin ? `From: ${found.origin}` : null, found.destination ? `To: ${found.destination}` : null]
|
||
.filter(Boolean)
|
||
.join('\n'),
|
||
list: events.length
|
||
? {
|
||
title: 'Scan history',
|
||
items: events.map((e) => ({
|
||
label: e.status || e.event || 'event',
|
||
meta: e.createdat ? parseDoormileTimestamp(e.createdat).format('DD MMM, hh:mm A') : undefined
|
||
}))
|
||
}
|
||
: undefined,
|
||
sourceCalls: [
|
||
{ name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'complete' },
|
||
logs
|
||
? { name: 'getConsignmentLogs', target: `/admin/consignments/${id}/logs`, status: 'complete', stats: `${events.length} events` }
|
||
: null
|
||
].filter(Boolean)
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// ---- L3: one tenant in depth -------------------------------------------
|
||
id: 'tenantDetail',
|
||
label: 'A tenant’s detail — e.g. "tell me about Acme Foods"',
|
||
match: (text) => {
|
||
if (!/\btenant\b|\babout\b|\bdetails?\s+(?:of|for)\b/i.test(text)) return null;
|
||
if (/\bhow many\b/i.test(text)) return null; // that's tenantList
|
||
const name = tenantNameCandidate(text) || riderNameFromWords(text);
|
||
return name ? { name } : null;
|
||
},
|
||
run: async ({ name }) => {
|
||
const tenants = (await getalltenants()) || [];
|
||
const match = bestNameMatch(name, tenants, (t) => t.tenantname);
|
||
if (!match) return null;
|
||
const [detail, locations] = await Promise.all([
|
||
getAdminTenant(match.tenantid).catch(() => null),
|
||
getTenantLocations(match.tenantid).catch(() => null)
|
||
]);
|
||
const t = detail || match;
|
||
const sites = Array.isArray(locations) ? locations : [];
|
||
return {
|
||
headline: `${t.tenantname}${t.status ? ` — ${t.status}` : ''}.`,
|
||
metric: { value: sites.length, label: 'Pickup locations' },
|
||
detail: [t.primaryemail ? `Email: ${t.primaryemail}` : null, t.contactno ? `Phone: ${t.contactno}` : null]
|
||
.filter(Boolean)
|
||
.join('\n'),
|
||
list: sites.length
|
||
? { title: 'Locations', items: sites.map((l) => ({ label: l.locationname || `Location ${l.locationid}`, meta: l.pincode })) }
|
||
: undefined,
|
||
sourceCalls: [
|
||
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
|
||
{ name: 'getAdminTenant', target: `/admin/tenants/${match.tenantid}`, status: detail ? 'complete' : 'error' },
|
||
{
|
||
name: 'getTenantLocations',
|
||
target: `/admin/tenants/${match.tenantid}/locations`,
|
||
status: locations ? 'complete' : 'error',
|
||
stats: `${sites.length} locations`
|
||
}
|
||
]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// ---- L4: trend over time ------------------------------------------------
|
||
//
|
||
// Ordered above the plain counting intents: "orders per day this week"
|
||
// contains "orders" and a range, so weekOrders would otherwise answer it
|
||
// with a single number and drop the "per day" entirely.
|
||
id: 'orderTrend',
|
||
label: 'Order trend — e.g. "orders per day this week"',
|
||
match: (text) => {
|
||
if (mentionsRiders(text) || mentionsDelay(text)) return null;
|
||
if (!TREND_TRIGGER.test(text)) return null;
|
||
if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null;
|
||
const byHour = /\bper\s+hour\b|\bhourly\b|\bby\s+hour\b/i.test(text);
|
||
const range = rangeFromWords(text);
|
||
if (range) return { start: range.start, end: range.end, label: range.label, byHour };
|
||
const day = dayFromWords(text);
|
||
return { start: day, end: day, label: describeDay(day), byHour: true };
|
||
},
|
||
run: async ({ start, end, label, byHour }) => {
|
||
const scan = await fetchBookingsInRange(start, end);
|
||
const buckets = trendBuckets(scan.rows, byHour);
|
||
if (!buckets.length) {
|
||
return { headline: `No orders to chart ${label}.`, sourceCalls: [scanCall(scan, '0 rows')] };
|
||
}
|
||
const max = Math.max(...buckets.map(([, n]) => n));
|
||
const peak = buckets.find(([, n]) => n === max);
|
||
return {
|
||
headline: `${countPhrase(scan, scan.rows.length)} order${scan.rows.length === 1 ? '' : 's'} ${label}, peaking at ${max} (${
|
||
peak[0]
|
||
}).`,
|
||
metric: { value: max, label: `Peak ${byHour ? 'hour' : 'day'} — ${peak[0]}` },
|
||
list: {
|
||
title: byHour ? 'Orders per hour' : 'Orders per day',
|
||
numbered: false,
|
||
items: buckets.map(([k, n]) => ({ label: k, meta: `${sparkBar(n, max)} ${n}` }))
|
||
},
|
||
detail: truncationNote(scan).trim() || undefined,
|
||
sourceCalls: [scanCall(scan, `${scan.rows.length} rows → ${buckets.length} buckets`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// ---- L4: rates ---------------------------------------------------------
|
||
id: 'orderRate',
|
||
label: 'A rate — e.g. "cancellation rate this week"',
|
||
match: (text) => {
|
||
if (mentionsRiders(text) || mentionsDelay(text)) return null;
|
||
if (!RATE_TRIGGER.test(text)) return null;
|
||
const group = statusFromWords(text);
|
||
if (!group) return null;
|
||
const range = rangeFromWords(text);
|
||
if (range) return { group, start: range.start, end: range.end, label: range.label };
|
||
if (mentionsAnyDate(text)) {
|
||
const day = dayFromWords(text);
|
||
return { group, start: day, end: day, label: describeDay(day) };
|
||
}
|
||
return { group, start: null, end: null, label: null };
|
||
},
|
||
run: async ({ group, start, end, label }) => {
|
||
const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
|
||
const rows = scan.rows;
|
||
const matched = rows.filter((b) => isInGroup(b.status, group));
|
||
const noun = ORDER_STATUS_LABELS[group].toLowerCase();
|
||
const scopeText = label ? ` ${label}` : '';
|
||
return {
|
||
headline: `${rateOf(matched.length, rows.length)} of orders are ${noun}${scopeText}.`,
|
||
metric: { value: rateOf(matched.length, rows.length), label: `${ORDER_STATUS_LABELS[group]} rate${scopeText}` },
|
||
stats: statusStats(rows),
|
||
detail:
|
||
`${matched.length} of ${rows.length} orders.` +
|
||
(rows.length < 20 ? ' Small sample — treat the percentage loosely.' : '') +
|
||
(label ? '' : '\nCovers every order, not just today.') +
|
||
truncationNote(scan),
|
||
sourceCalls: [scanCall(scan, `${matched.length}/${rows.length} ${noun}`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// ---- Composable order questions ----------------------------------------
|
||
//
|
||
// Every other intent in this file answers ONE dimension and silently
|
||
// discards the rest of the sentence: "delivered orders for Acme this week"
|
||
// was answered by statusBreakdown, which had nowhere to put the tenant.
|
||
// This intent composes status x batch x date x tenant x rider, and also
|
||
// handles rankings ("top 5 tenants by orders").
|
||
//
|
||
// It deliberately claims a question ONLY when it carries two or more
|
||
// filter dimensions, or asks for a ranking — precisely the shapes the
|
||
// single-intent path cannot express. Anything simpler still routes to the
|
||
// proven intents below, so this adds capability without re-routing what
|
||
// already works.
|
||
//
|
||
// `run` returns null when a named tenant/rider doesn't resolve, so an
|
||
// unrecognised name falls through rather than being silently ignored.
|
||
id: 'orderQuery',
|
||
label: 'Composite order question — e.g. "delivered orders for Acme this week"',
|
||
match: (text) => {
|
||
if (mentionsDelay(text)) return null;
|
||
if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null;
|
||
|
||
const ranking = rankingFromWords(text);
|
||
const status = statusFromWords(text);
|
||
const batch = batchFromWords(text);
|
||
const range = rangeFromWords(text);
|
||
const dated = mentionsAnyDate(text);
|
||
const riderName = riderNameFromWords(text);
|
||
const tenantName = tenantNameCandidate(text);
|
||
|
||
// A date is NOT counted as a dimension. Every intent below already
|
||
// handles dates, so counting it would make "how many cancelled orders
|
||
// today" look composite and re-route four working questions away from
|
||
// the intents that answer them best. What the single-intent path
|
||
// genuinely cannot express is two or more of
|
||
// status / batch / tenant / rider at once.
|
||
const dimensions = [status, batch, riderName, tenantName].filter(Boolean).length;
|
||
if (!ranking && dimensions < 2) return null;
|
||
|
||
let scope = { start: null, end: null, label: null };
|
||
if (range) {
|
||
scope = { start: range.start, end: range.end, label: range.label };
|
||
} else if (dated) {
|
||
const day = dayFromWords(text);
|
||
scope = { start: day, end: day, label: describeDay(day) };
|
||
}
|
||
return { status, batch, riderName, tenantName, ranking, ...scope };
|
||
},
|
||
run: async ({ status, batch, riderName, tenantName, ranking, start, end, label }) => {
|
||
const sourceCalls = [];
|
||
|
||
// ---- resolve named entities before touching bookings ----
|
||
let tenant = null;
|
||
let tenants = null;
|
||
if (tenantName || ranking?.groupBy === 'tenant') {
|
||
tenants = (await getalltenants()) || [];
|
||
sourceCalls.push({ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` });
|
||
if (tenantName) {
|
||
tenant = bestNameMatch(tenantName, tenants, (t) => t.tenantname);
|
||
// Unrecognised name — don't quietly drop the filter and answer a
|
||
// broader question, which is exactly the bug this intent exists to
|
||
// fix. Fall through instead.
|
||
if (!tenant) return null;
|
||
}
|
||
}
|
||
|
||
let rider = null;
|
||
let milers = null;
|
||
if (riderName || ranking?.groupBy === 'rider') {
|
||
milers = (await getMilers()) || [];
|
||
sourceCalls.push({ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: `${milers.length} riders` });
|
||
if (riderName) {
|
||
rider = bestNameMatch(riderName, milers, (m) => m.displayname || m.name);
|
||
if (!rider) return null;
|
||
}
|
||
}
|
||
|
||
const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
|
||
sourceCalls.push(scanCall(scan, `${scan.rows.length} in scope`));
|
||
|
||
// ---- apply every named filter ----
|
||
let rows = scan.rows;
|
||
if (status) rows = rows.filter((b) => isInGroup(b.status, status));
|
||
if (batch) rows = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch);
|
||
if (tenant) rows = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid));
|
||
if (rider) rows = rows.filter((b) => b.assignedmileruserid === (rider.userid ?? rider.milerid));
|
||
|
||
const scopeText = label ? ` ${label}` : '';
|
||
const filterText = [
|
||
status ? ORDER_STATUS_LABELS[status].toLowerCase() : null,
|
||
batch ? getBatchLabel(batch) : null,
|
||
tenant ? `for ${tenant.tenantname}` : null,
|
||
rider ? `by ${rider.displayname || rider.name}` : null
|
||
]
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
|
||
// ---- ranking ----
|
||
if (ranking) {
|
||
const nameFor = (b) => {
|
||
if (ranking.groupBy === 'tenant') {
|
||
return (tenants || []).find((t) => Number(t.tenantid) === Number(b.tenantid))?.tenantname || `Tenant #${b.tenantid}`;
|
||
}
|
||
if (ranking.groupBy === 'rider') {
|
||
if (!b.assignedmileruserid) return 'Unassigned';
|
||
const m = (milers || []).find((x) => x.userid === b.assignedmileruserid);
|
||
return m?.displayname || m?.name || `Rider #${b.assignedmileruserid}`;
|
||
}
|
||
if (ranking.groupBy === 'status') return ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] || b.status;
|
||
if (ranking.groupBy === 'batch') return getBatchLabel(getRowBatchId({ orderdate: b.createdat })) || 'No batch';
|
||
return 'Unknown';
|
||
};
|
||
// No hub field exists on a booking (see hubBreakdownForLate) — refuse
|
||
// rather than invent a grouping.
|
||
if (ranking.groupBy === 'hub') return null;
|
||
|
||
const counts = {};
|
||
rows.forEach((b) => {
|
||
const k = nameFor(b);
|
||
counts[k] = (counts[k] || 0) + 1;
|
||
});
|
||
const ordered = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
||
const top = ordered.slice(0, ranking.limit);
|
||
if (!top.length) {
|
||
return {
|
||
headline: `No orders to rank${scopeText}.`,
|
||
sourceCalls
|
||
};
|
||
}
|
||
return {
|
||
headline: `Top ${top.length} ${ranking.groupBy}${top.length === 1 ? '' : 's'} by orders${scopeText}: ${top[0][0]} (${
|
||
top[0][1]
|
||
}).`,
|
||
metric: { value: top[0][1], label: `${top[0][0]} — most orders${scopeText}` },
|
||
stats: top.map(([name, n]) => ({ label: name, value: n, color: STATUS.info })),
|
||
detail:
|
||
`${ordered.length} ${ranking.groupBy}${ordered.length === 1 ? '' : 's'} with orders${scopeText}.` +
|
||
(label ? '' : '\nCovers every order, not just today.') +
|
||
truncationNote(scan),
|
||
sourceCalls
|
||
};
|
||
}
|
||
|
||
// ---- plain composite count ----
|
||
return {
|
||
headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'}${
|
||
filterText ? ` ${filterText}` : ''
|
||
}${scopeText}.`,
|
||
metric: { value: rows.length, label: [filterText, label].filter(Boolean).join(' · ') || 'Orders' },
|
||
stats: statusStats(rows),
|
||
list: rows.length
|
||
? {
|
||
title: 'Matching orders',
|
||
items: rows.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
|
||
}
|
||
: undefined,
|
||
detail:
|
||
(rows.length ? '' : 'Nothing matched every part of that question.') +
|
||
(label ? '' : '\nCovers every order, not just today.') +
|
||
truncationNote(scan),
|
||
sourceCalls
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// Ordered early, right after the order-id lookup — "rider(s)" is a
|
||
// strong, unambiguous domain signal and must win before any of the
|
||
// generic order-status/date intents below get a chance to misfire on a
|
||
// shared word like "active". See the mentionsRiders comment above.
|
||
id: 'riderCounts',
|
||
label: 'Rider availability — e.g. "how many riders are active"',
|
||
match: (text) => (mentionsRiders(text) ? {} : null),
|
||
run: async () => {
|
||
const summary = await getallridersummary();
|
||
return {
|
||
headline: `${summary?.active ?? 0} active riders (${summary?.available ?? 0} available, ${
|
||
summary?.onDelivery ?? 0
|
||
} on a delivery) out of ${summary?.total ?? 0} total.`,
|
||
detail: `${summary?.inactive ?? 0} inactive/offline.`,
|
||
sourceCalls: [{ name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'tenantList',
|
||
label: 'Tenant count — e.g. "how many tenants do we have"',
|
||
match: (text) => (/\btenants?\b/i.test(text) && /\bhow many\b|\blist\b|\ball\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const tenants = (await getalltenants()) || [];
|
||
return {
|
||
headline: `${tenants.length} tenant${tenants.length === 1 ? '' : 's'} total.`,
|
||
metric: { value: tenants.length, label: 'Tenants' },
|
||
// Full list, not the first ten. "…and 4 more" left the operator with
|
||
// no way to see the rest at all.
|
||
list: {
|
||
title: 'Tenants',
|
||
items: tenants
|
||
.filter((t) => t.tenantname)
|
||
.map((t) => ({ label: t.tenantname, meta: t.tenantid != null ? `#${t.tenantid}` : undefined }))
|
||
},
|
||
sourceCalls: [{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// Ordered high: "which hubs are experiencing delays" contains "hubs" and
|
||
// would otherwise be answered by hubStatus with a hub inventory, and
|
||
// "which orders are delayed" contains "orders" and would fall through to
|
||
// totalOrders. Both now also guard with mentionsDelay.
|
||
id: 'delayedOrders',
|
||
label: 'Late and at-risk orders — e.g. "which orders are delayed"',
|
||
match: (text) => {
|
||
if (mentionsRiders(text)) return null;
|
||
if (!mentionsDelay(text)) return null;
|
||
const range = rangeFromWords(text);
|
||
if (range) return { start: range.start, end: range.end, label: range.label };
|
||
if (mentionsAnyDate(text)) {
|
||
const day = dayFromWords(text);
|
||
return { start: day, end: day, label: describeDay(day) };
|
||
}
|
||
// Lateness is a state question — "what is late right now" — so with no
|
||
// date named it covers every open order, not just today's.
|
||
return { start: null, end: null, label: null };
|
||
},
|
||
run: async ({ start, end, label }) => {
|
||
const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
|
||
const rows = scan.rows;
|
||
const now = dayjs();
|
||
|
||
const buckets = { late: [], 'at-risk': [], 'on-time': [], 'no-eta': [], closed: [] };
|
||
rows.forEach((b) => buckets[delayState(b, now)].push(b));
|
||
|
||
const open = rows.length - buckets.closed.length;
|
||
const measurable = buckets.late.length + buckets['at-risk'].length + buckets['on-time'].length;
|
||
const scopeNote = label ? ` ${label}` : '';
|
||
|
||
// Coverage guard. Reporting "0 delayed" when nothing carries an ETA
|
||
// would be a falsely reassuring answer about data we simply don't have.
|
||
if (open > 0 && measurable === 0) {
|
||
return {
|
||
headline: "I can't tell which orders are late.",
|
||
detail: `None of the ${open} open order${
|
||
open === 1 ? '' : 's'
|
||
}${scopeNote} carry a delivery ETA, so there is nothing to measure lateness against.`,
|
||
sourceCalls: [scanCall(scan, `${rows.length} scanned, 0 with an ETA`)]
|
||
};
|
||
}
|
||
|
||
const late = buckets.late;
|
||
const hubs = await hubBreakdownForLate(late);
|
||
|
||
return {
|
||
headline: `${countPhrase(scan, late.length)} order${late.length === 1 ? '' : 's'} running late${scopeNote}.`,
|
||
metric: { value: late.length, label: `Late${label ? `, ${label}` : ' right now'}` },
|
||
list: late.length
|
||
? { title: 'Late orders', items: late.map((b) => ({ label: bookingLabel(b), meta: formatLateness(b, now) })) }
|
||
: undefined,
|
||
stats: [
|
||
{ label: 'Late', value: late.length, color: STATUS.cancelled },
|
||
{ label: 'Due within 1h', value: buckets['at-risk'].length, color: STATUS.pending },
|
||
{ label: 'On time', value: buckets['on-time'].length, color: STATUS.delivered },
|
||
{ label: 'No ETA', value: buckets['no-eta'].length, color: STATUS.muted }
|
||
],
|
||
detail:
|
||
[
|
||
late.length ? null : 'Nothing is past its promised delivery time.',
|
||
hubs ? `By hub: ${hubs.map(([hub, n]) => `${hub} ${n}`).join(', ')}` : null,
|
||
buckets['no-eta'].length
|
||
? `${buckets['no-eta'].length} open order${
|
||
buckets['no-eta'].length === 1 ? '' : 's'
|
||
} have no ETA and are not counted either way.`
|
||
: null,
|
||
label ? null : 'Covers every open order, not just today.'
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n') + truncationNote(scan),
|
||
sourceCalls: [
|
||
scanCall(scan, `${open} open → ${late.length} late, ${buckets['at-risk'].length} at risk`),
|
||
hubs
|
||
? {
|
||
name: 'getMilerSummary',
|
||
target: '/admin/milers/summary',
|
||
status: 'complete',
|
||
stats: `${hubs.length} hubs with late orders`
|
||
}
|
||
: null
|
||
].filter(Boolean)
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// A rare, explicit trigger phrase ("vs"/"versus"/"compare[d] to") —
|
||
// ordered ahead of every generic order/revenue intent below so a
|
||
// comparison question can't be swallowed by totalOrders/revenueTotal
|
||
// (neither of which ever returns null, so whichever gets checked first
|
||
// wins the whole question).
|
||
id: 'comparisonIntent',
|
||
label: 'Compare two periods — e.g. "orders today vs yesterday"',
|
||
match: (text) => {
|
||
if (mentionsRiders(text)) return null;
|
||
if (!COMPARE_TRIGGER.test(text)) return null;
|
||
const parts = text.split(COMPARE_TRIGGER);
|
||
if (parts.length < 2) return null;
|
||
const left = parts[0];
|
||
const right = parts.slice(1).join(' ');
|
||
const leftRange = rangeFromWords(left);
|
||
const rightRange = rangeFromWords(right);
|
||
const isRevenue = /\brevenue\b|\bcharges?\b|\bearnings?\b/i.test(text);
|
||
return {
|
||
isRevenue,
|
||
left: leftRange || { start: dayFromWords(left), end: dayFromWords(left), label: describeDay(dayFromWords(left)) },
|
||
right: rightRange || { start: dayFromWords(right), end: dayFromWords(right), label: describeDay(dayFromWords(right)) }
|
||
};
|
||
},
|
||
run: async ({ isRevenue, left, right }) => {
|
||
const [leftScan, rightScan] = await Promise.all([
|
||
fetchBookingsInRange(left.start, left.end),
|
||
fetchBookingsInRange(right.start, right.end)
|
||
]);
|
||
const leftRows = leftScan.rows;
|
||
const rightRows = rightScan.rows;
|
||
// A comparison across a truncated scan isn't just imprecise, it's
|
||
// directionally unsafe — one side can be capped and the other not.
|
||
const truncated = leftScan.truncated || rightScan.truncated;
|
||
const sourceCalls = [
|
||
scanCall(leftScan, `${leftRows.length} (${left.label})`),
|
||
scanCall(rightScan, `${rightRows.length} (${right.label})`)
|
||
];
|
||
const caveat = truncated ? '\nBoth sides come from a capped scan — treat the difference as indicative, not exact.' : undefined;
|
||
if (isRevenue) {
|
||
const leftTotal = revenueOf(leftRows);
|
||
const rightTotal = revenueOf(rightRows);
|
||
const diff = leftTotal - rightTotal;
|
||
return {
|
||
headline: `${formatRupees(leftTotal)} (${left.label}) vs ${formatRupees(rightTotal)} (${right.label}) — ${
|
||
diff >= 0 ? 'up' : 'down'
|
||
} ${formatRupees(Math.abs(diff))}.`,
|
||
detail: caveat,
|
||
sourceCalls
|
||
};
|
||
}
|
||
const diff = leftRows.length - rightRows.length;
|
||
return {
|
||
headline: `${countPhrase(leftScan, leftRows.length)} order${leftRows.length === 1 ? '' : 's'} (${left.label}) vs ${countPhrase(
|
||
rightScan,
|
||
rightRows.length
|
||
)} order${rightRows.length === 1 ? '' : 's'} (${right.label}) — ${diff >= 0 ? 'up' : 'down'} ${Math.abs(diff)}.`,
|
||
detail: caveat,
|
||
sourceCalls
|
||
};
|
||
}
|
||
},
|
||
// Fleet/ops intents below — each is a live count against one more of the
|
||
// console's own resources (hubs, vehicles, tripsheets, exceptions, app
|
||
// users, customers, pricing, consignments, partners, competitor branches,
|
||
// carrier pricing), so the bot's coverage grows the same way the rest of
|
||
// this console does: as new admin resources get their own page here, add
|
||
// a matching intent here too, always reading the SAME getX() call that
|
||
// page's own table uses — never a bespoke fetch. Each trigger word is
|
||
// domain-unique enough that none of them need a mentionsRiders-style
|
||
// guard against the order/rider/tenant intents above.
|
||
{
|
||
// Ahead of hubStatus so "status of hub Chennai" resolves to one hub
|
||
// instead of the full list.
|
||
id: 'hubLookup',
|
||
label: 'Look up a specific hub — e.g. "status of hub Chennai"',
|
||
match: (text) => {
|
||
if (!/\bhub(s)?\b/i.test(text) || !LOOKUP_TRIGGER.test(text)) return null;
|
||
const name = nameAfterKeyword(text, 'hub');
|
||
return name ? { name } : null;
|
||
},
|
||
run: async ({ name }) => {
|
||
const hubs = (await getHubs()) || [];
|
||
const needle = name.toLowerCase();
|
||
const found = hubs.find((h) =>
|
||
String(h.hubname || '')
|
||
.toLowerCase()
|
||
.includes(needle)
|
||
);
|
||
if (!found) return null;
|
||
return {
|
||
headline: `${found.hubname} — ${found.status || 'status unknown'}.`,
|
||
detail: found.address ? `Address: ${found.address}` : undefined,
|
||
sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `matched "${name}"` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'hubStatus',
|
||
label: 'Hub status — e.g. "current hub status"',
|
||
match: (text) => (/\bhub(s)?\b/i.test(text) && !mentionsDelay(text) ? {} : null),
|
||
run: async () => {
|
||
const hubs = (await getHubs()) || [];
|
||
const active = hubs.filter((h) => String(h.status || '').toLowerCase() === 'active').length;
|
||
return {
|
||
headline: `${hubs.length} hub${hubs.length === 1 ? '' : 's'} total, ${active} active.`,
|
||
metric: { value: active, label: `Active of ${hubs.length} hubs` },
|
||
list: { title: 'Hubs', items: hubs.map((h) => ({ label: h.hubname || `Hub #${h.hubid}`, meta: h.status || 'unknown' })) },
|
||
sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `${hubs.length} hubs` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// Ahead of vehicleStatus so "find vehicle TN01AB1234" resolves to one
|
||
// vehicle instead of the fleet aggregate.
|
||
id: 'vehicleLookup',
|
||
label: 'Look up a specific vehicle — e.g. "find vehicle TN01AB1234"',
|
||
match: (text) => {
|
||
if (!/\bvehicles?\b/i.test(text) || !LOOKUP_TRIGGER.test(text)) return null;
|
||
const name = nameAfterKeyword(text, 'vehicle');
|
||
return name ? { name } : null;
|
||
},
|
||
run: async ({ name }) => {
|
||
const vehicles = (await getVehicles()) || [];
|
||
const needle = name.toLowerCase().replace(/\s+/g, '');
|
||
const found = vehicles.find((v) =>
|
||
String(v.vehicleno || v.vehiclenumber || '')
|
||
.toLowerCase()
|
||
.replace(/\s+/g, '')
|
||
.includes(needle)
|
||
);
|
||
if (!found) return null;
|
||
return {
|
||
headline: `${found.vehicleno || found.vehiclenumber} — ${found.status || 'status unknown'}.`,
|
||
detail: found.vehicletype ? `Type: ${found.vehicletype}` : undefined,
|
||
sourceCalls: [{ name: 'getVehicles', target: '/admin/vehicles', status: 'complete', stats: `matched "${name}"` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'vehicleStatus',
|
||
label: 'Vehicle status — e.g. "how many vehicles are available"',
|
||
match: (text) => (/\bvehicles?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const vehicles = (await getVehicles()) || [];
|
||
const available = vehicles.filter((v) => String(v.status || '').toLowerCase() === 'available').length;
|
||
return {
|
||
headline: `${vehicles.length} vehicle${vehicles.length === 1 ? '' : 's'} total, ${available} available.`,
|
||
detail: vehicles.length ? `By type: ${summarizeByField(vehicles, 'vehicletype')}` : undefined,
|
||
sourceCalls: [{ name: 'getVehicles', target: '/admin/vehicles', status: 'complete', stats: `${vehicles.length} vehicles` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'tripsheetStatus',
|
||
label: 'Tripsheet status — e.g. "how many tripsheets are dispatched"',
|
||
match: (text) => (/\btrip\s*sheets?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const trips = (await getTripsheets()) || [];
|
||
return {
|
||
headline: `${trips.length} tripsheet${trips.length === 1 ? '' : 's'} total.`,
|
||
detail: trips.length ? `Statuses: ${summarizeByField(trips, 'status')}` : undefined,
|
||
sourceCalls: [{ name: 'getTripsheets', target: '/admin/tripsheets', status: 'complete', stats: `${trips.length} tripsheets` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'exceptionStatus',
|
||
label: 'Exceptions — e.g. "how many open exceptions"',
|
||
match: (text) => (/\bexceptions?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const exceptions = (await getExceptions()) || [];
|
||
return {
|
||
headline: `${exceptions.length} exception${exceptions.length === 1 ? '' : 's'} total.`,
|
||
detail: exceptions.length ? `By severity: ${summarizeByField(exceptions, 'severity')}` : undefined,
|
||
sourceCalls: [{ name: 'getExceptions', target: '/admin/exceptions', status: 'complete', stats: `${exceptions.length} exceptions` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'appUserCount',
|
||
label: 'App users — e.g. "how many app users do we have"',
|
||
match: (text) => (/\bapp\s*users?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const users = (await getAppUsers()) || [];
|
||
return {
|
||
headline: `${users.length} app user${users.length === 1 ? '' : 's'} total.`,
|
||
detail: users.length ? `Statuses: ${summarizeByField(users, 'status')}` : undefined,
|
||
sourceCalls: [{ name: 'getAppUsers', target: '/admin/users', status: 'complete', stats: `${users.length} users` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'customerCount',
|
||
label: 'Customers — e.g. "how many customers do we have"',
|
||
match: (text) => (/\bcustomers?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const customers = (await getAdminCustomers()) || [];
|
||
return {
|
||
headline: `${customers.length} customer${customers.length === 1 ? '' : 's'} total.`,
|
||
sourceCalls: [{ name: 'getAdminCustomers', target: '/admin/customers', status: 'complete', stats: `${customers.length} customers` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'pricingCount',
|
||
label: 'Pricing rules — e.g. "how many pricing rules are configured"',
|
||
match: (text) => (/\bpricing\b|\bprice\s*list\b|\brate\s*card\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const pricing = (await getAdminPricing()) || [];
|
||
return {
|
||
headline: `${pricing.length} pricing rule${pricing.length === 1 ? '' : 's'} configured.`,
|
||
detail: pricing.length ? `By vehicle type: ${summarizeByField(pricing, 'vehicletype')}` : undefined,
|
||
sourceCalls: [{ name: 'getAdminPricing', target: '/admin/pricing', status: 'complete', stats: `${pricing.length} rules` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'consignmentStatus',
|
||
label: 'Consignments — e.g. "how many consignments do we have"',
|
||
match: (text) => (/\bconsignments?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const consignments = (await getConsignments()) || [];
|
||
return {
|
||
headline: `${consignments.length} consignment${consignments.length === 1 ? '' : 's'} total.`,
|
||
detail: consignments.length ? `Statuses: ${summarizeByField(consignments, 'status')}` : undefined,
|
||
sourceCalls: [
|
||
{ name: 'getConsignments', target: '/admin/consignments', status: 'complete', stats: `${consignments.length} consignments` }
|
||
]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'partnerCount',
|
||
label: 'Partners — e.g. "how many partners do we have"',
|
||
match: (text) => (/\bpartners?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const partners = (await getPartners()) || [];
|
||
return {
|
||
headline: `${partners.length} partner${partners.length === 1 ? '' : 's'} total.`,
|
||
sourceCalls: [{ name: 'getPartners', target: '/admin/partners', status: 'complete', stats: `${partners.length} partners` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'competitorBranchCount',
|
||
label: 'Competitor branches — e.g. "how many competitor branches are tracked"',
|
||
match: (text) => (/\bcompetitors?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const resp = await getCompetitorBranches(1, BULK_PAGESIZE);
|
||
const branches = resp?.data || [];
|
||
const total = resp?.total ?? branches.length;
|
||
return {
|
||
headline: `${total} competitor branch${total === 1 ? '' : 'es'} tracked.`,
|
||
detail: branches.length ? `By city: ${summarizeByField(branches, 'city')}` : undefined,
|
||
sourceCalls: [
|
||
{ name: 'getCompetitorBranches', target: '/admin/competitor-branches', status: 'complete', stats: `${total} branches` }
|
||
]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'carrierPricingCount',
|
||
label: 'Carrier pricing — e.g. "how many carrier pricing rules"',
|
||
match: (text) => (/\bcarriers?\b/i.test(text) ? {} : null),
|
||
run: async () => {
|
||
const resp = await getCarrierPricing(1, BULK_PAGESIZE);
|
||
const rows = resp?.data || [];
|
||
const total = resp?.total ?? rows.length;
|
||
return {
|
||
headline: `${total} carrier pricing rule${total === 1 ? '' : 's'} configured.`,
|
||
sourceCalls: [{ name: 'getCarrierPricing', target: '/admin/carrier-pricing', status: 'complete', stats: `${total} rules` }]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// Ordered ahead of the generic order/status intents: "summary"/"overview"
|
||
// is an explicit, rare trigger, but a phrasing like "order summary today"
|
||
// would otherwise be swallowed by totalOrders (whose `run` never returns
|
||
// null, so whichever is checked first wins the whole question).
|
||
id: 'opsSummary',
|
||
label: 'Operations summary — e.g. "give me today\'s operations summary"',
|
||
match: (text) => {
|
||
if (mentionsRiders(text)) return null;
|
||
if (!/\bsummary\b|\boverview\b|\bsnapshot\b|\bhow\s+are\s+we\s+doing\b|\bops\b|\boperations?\b/i.test(text)) return null;
|
||
const range = rangeFromWords(text);
|
||
if (range) return { start: range.start, end: range.end, rangeLabel: range.label };
|
||
const day = dayFromWords(text);
|
||
return { start: day, end: day, rangeLabel: describeDay(day) };
|
||
},
|
||
run: async ({ start, end, rangeLabel }) => {
|
||
// Two independent real calls — bookings for the tallies, milers for
|
||
// the fleet line. A rider-summary failure degrades that one line
|
||
// rather than failing the whole answer.
|
||
const scan = await fetchBookingsInRange(start, end);
|
||
const rows = scan.rows;
|
||
const riders = await getallridersummary().catch(() => null);
|
||
return {
|
||
headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} ${rangeLabel}.`,
|
||
metric: { value: rows.length, label: `Orders ${rangeLabel}${scan.truncated ? ' (at least)' : ''}` },
|
||
stats: statusStats(rows),
|
||
detail:
|
||
[
|
||
riders ? `${riders.active} of ${riders.total} riders active (${riders.available} available).` : null,
|
||
`${formatRupees(revenueOf(rows))} estimated revenue, excluding cancelled.`
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n') + truncationNote(scan),
|
||
sourceCalls: [
|
||
scanCall(scan, `${rows.length} orders ${rangeLabel}`),
|
||
riders
|
||
? { name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${riders.total} riders` }
|
||
: { name: 'getallridersummary', target: '/admin/milers', status: 'error', errorMessage: 'Rider summary unavailable' }
|
||
]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'batchCount',
|
||
label: 'Orders in a batch — e.g. "morning batch orders today"',
|
||
match: (text) => {
|
||
const batch = batchFromWords(text);
|
||
if (!batch) return null;
|
||
return { batch, day: dayFromWords(text) };
|
||
},
|
||
run: async ({ batch, day }) => {
|
||
const scan = await fetchBookingsForDay(day);
|
||
const rows = scan.rows;
|
||
const matched = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch);
|
||
return {
|
||
headline: `${countPhrase(scan, matched.length)} order${matched.length === 1 ? '' : 's'} in the ${getBatchLabel(
|
||
batch
|
||
)} ${describeDay(day)}.`,
|
||
metric: { value: matched.length, label: `${getBatchLabel(batch)} ${describeDay(day)}` },
|
||
list: matched.length
|
||
? {
|
||
title: `${getBatchLabel(batch)} orders`,
|
||
items: matched.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
|
||
}
|
||
: undefined,
|
||
detail: (matched.length ? '' : 'No orders fall in this batch for that day.') + truncationNote(scan),
|
||
sourceCalls: [scanCall(scan, `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'statusBreakdown',
|
||
label: 'Orders by status — e.g. "how many delivered orders today"',
|
||
match: (text) => {
|
||
// "active" is a real order status AND common rider-availability
|
||
// language ("riders active today") — riderCounts already runs first,
|
||
// but refuse explicitly too so this can't regress if reordered.
|
||
if (mentionsRiders(text) || mentionsDelay(text)) return null;
|
||
const group = statusFromWords(text);
|
||
if (!group) return null;
|
||
const range = rangeFromWords(text);
|
||
if (range) return { group, start: range.start, end: range.end, label: range.label };
|
||
if (mentionsAnyDate(text)) {
|
||
const day = dayFromWords(text);
|
||
return { group, start: day, end: day, label: describeDay(day) };
|
||
}
|
||
// No date named at all. "How many orders are assigned" is a question
|
||
// about the CURRENT STATE of the queue, which is what the Orders page's
|
||
// tabs show — they apply no date filter either. Silently scoping it to
|
||
// orders *created today* answered a different question and returned 0
|
||
// while the page showed 19.
|
||
return { group, start: null, end: null, label: null };
|
||
},
|
||
run: async ({ group, start, end, label }) => {
|
||
const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
|
||
const rows = scan.rows;
|
||
const matched = rows.filter((b) => isInGroup(b.status, group));
|
||
const noun = ORDER_STATUS_LABELS[group].toLowerCase();
|
||
const whenHeadline = label ? ` ${label}` : '';
|
||
return {
|
||
headline: `${countPhrase(scan, matched.length)} ${noun} order${matched.length === 1 ? '' : 's'}${whenHeadline}.`,
|
||
metric: { value: matched.length, label: `${ORDER_STATUS_LABELS[group]}${label ? `, ${label}` : ''}` },
|
||
stats: statusStats(rows),
|
||
detail:
|
||
(label
|
||
? `Out of ${rows.length} orders created ${label}.`
|
||
: `Out of ${rows.length} orders in total. Matches the Orders page's ${ORDER_STATUS_LABELS[group]} tab, which is also unfiltered by date — ask "${noun} orders today" to scope it.`) +
|
||
`\nCounts ${statusesInGroup(group).join(', ')}.` +
|
||
truncationNote(scan),
|
||
sourceCalls: [scanCall(scan, `${rows.length} total → ${matched.length} ${noun}`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'revenueTotal',
|
||
label: 'Revenue/charges total — e.g. "total revenue today"',
|
||
match: (text) => {
|
||
if (!/\brevenue\b|\bcharges?\b|\bamount\b|\bearnings?\b|\bcollections?\b/i.test(text)) return null;
|
||
const range = rangeFromWords(text);
|
||
if (range) return { start: range.start, end: range.end, rangeLabel: range.label };
|
||
const day = dayFromWords(text);
|
||
return { start: day, end: day, rangeLabel: describeDay(day) };
|
||
},
|
||
run: async ({ start, end, rangeLabel }) => {
|
||
const scan = await fetchBookingsInRange(start, end);
|
||
const rows = scan.rows;
|
||
const billable = rows.filter((b) => !isCancelled(b));
|
||
const total = revenueOf(rows);
|
||
const cancelledCount = rows.length - billable.length;
|
||
return {
|
||
// "Estimated" is not hedging — the figure is the sum of
|
||
// serviceoptions[].estimatedprice, which is a quote, not a settled
|
||
// amount. Calling it "revenue" flat was the misleading part.
|
||
headline: `${formatRupees(total)} estimated across ${billable.length} order${billable.length === 1 ? '' : 's'} ${rangeLabel}.`,
|
||
metric: { value: formatRupees(total), label: `Estimated, ${rangeLabel}` },
|
||
detail:
|
||
[
|
||
billable.length ? `Average ${formatRupees(total / billable.length)} per order.` : null,
|
||
cancelledCount ? `${cancelledCount} cancelled order${cancelledCount === 1 ? '' : 's'} excluded.` : null
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n') + truncationNote(scan),
|
||
sourceCalls: [scanCall(scan, `${billable.length} billable orders, ${formatRupees(total)}`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'tenantCount',
|
||
label: 'Orders for a tenant — e.g. "orders for <tenant name> today"',
|
||
match: (text) => {
|
||
if (mentionsRiders(text)) return null;
|
||
if (!/\bfor\b/i.test(text) && !/\btenant\b/i.test(text)) return null;
|
||
return { text, day: dayFromWords(text) };
|
||
},
|
||
run: async ({ text, day }) => {
|
||
const tenant = await resolveTenant(text);
|
||
// No tenant name recognised in the question — don't guess which one
|
||
// was meant, fall through to the next intent instead.
|
||
if (!tenant) return null;
|
||
const scan = await fetchBookingsForDay(day);
|
||
const rows = scan.rows;
|
||
const matched = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid));
|
||
return {
|
||
headline: `${countPhrase(scan, matched.length)} order${matched.length === 1 ? '' : 's'} for ${tenant.tenantname} ${describeDay(
|
||
day
|
||
)}.`,
|
||
metric: { value: matched.length, label: `${tenant.tenantname}, ${describeDay(day)}` },
|
||
stats: statusStats(matched),
|
||
detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.` + truncationNote(scan),
|
||
sourceCalls: [
|
||
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete' },
|
||
scanCall(scan, `${rows.length} total → ${matched.length} for ${tenant.tenantname}`)
|
||
]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
id: 'weekOrders',
|
||
label: 'Orders this week — e.g. "how many orders this week"',
|
||
match: (text) => {
|
||
const range = rangeFromWords(text);
|
||
if (!range) return null;
|
||
return range;
|
||
},
|
||
run: async ({ start, end, label }) => {
|
||
const scan = await fetchBookingsInRange(start, end);
|
||
const rows = scan.rows;
|
||
return {
|
||
headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} ${label}.`,
|
||
metric: { value: rows.length, label: `Orders ${label}${scan.truncated ? ' (at least)' : ''}` },
|
||
stats: statusStats(rows),
|
||
detail: (rows.length ? `Statuses: ${summarizeStatuses(rows)}` : '') + truncationNote(scan),
|
||
sourceCalls: [scanCall(scan, `${rows.length} matched (${start} to ${end})`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// Deliberately last — the broadest match ("order"/"orders" alone), so
|
||
// every more specific intent above gets first refusal.
|
||
id: 'totalOrders',
|
||
label: 'Total order count — e.g. "how many orders today"',
|
||
match: (text) =>
|
||
!mentionsRiders(text) && !mentionsDelay(text) && /\border(s)?\b|\bbooking(s)?\b/i.test(text) ? { day: dayFromWords(text) } : null,
|
||
run: async ({ day }) => {
|
||
const scan = await fetchBookingsForDay(day);
|
||
const rows = scan.rows;
|
||
return {
|
||
headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} created ${describeDay(day)}.`,
|
||
metric: { value: rows.length, label: `Orders ${describeDay(day)}${scan.truncated ? ' (at least)' : ''}` },
|
||
stats: statusStats(rows),
|
||
detail: truncationNote(scan).trim() || undefined,
|
||
sourceCalls: [scanCall(scan, `${rows.length} matched`)]
|
||
};
|
||
}
|
||
},
|
||
{
|
||
// LAST in the catalog on purpose: every operational intent gets first
|
||
// refusal, so this can only ever claim a question none of them recognised.
|
||
// Its trigger is deliberately narrow — a question that merely CONTAINS the
|
||
// word "doormile" ("how many doormile orders today") must still route to
|
||
// the intent that can actually count it.
|
||
id: 'aboutDoormile',
|
||
label: 'What Doormile is, and what I can answer — e.g. "what is doormile"',
|
||
match: (text) => (ABOUT_TRIGGER.test(text) ? {} : null),
|
||
run: async () => ({
|
||
// Only what this console demonstrably does. Nothing here is a claim about
|
||
// the company, its coverage, its pricing or its history — none of that is
|
||
// in this app, and inventing it would be exactly the failure mode the
|
||
// whole catalog is built to avoid. doormile.com is where that lives.
|
||
headline: 'Doormile is the delivery operation this console runs.',
|
||
detail:
|
||
'From here you manage orders and deliveries, dispatch riders in batches, run hubs and vehicles, and handle tenants, pricing and reports. I answer questions about that live data — I don’t hold Doormile’s own company information, so for anything beyond day-to-day operations see doormile.com.',
|
||
list: {
|
||
title: 'What I can answer',
|
||
numbered: false,
|
||
items: [
|
||
{ label: 'Orders', meta: 'counts by status, batch, tenant or rider · revenue · delays · one order by its number' },
|
||
{ label: 'Riders', meta: 'how many are active · what a named rider has done' },
|
||
{ label: 'Fleet', meta: 'hubs, vehicles, tripsheets and exceptions' },
|
||
{ label: 'Business', meta: 'tenants, customers, pricing, consignments, partners' },
|
||
{ label: 'Creating', meta: 'a customer, one order, or many from a spreadsheet' }
|
||
]
|
||
},
|
||
sourceCalls: []
|
||
})
|
||
}
|
||
];
|
||
|
||
const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i]));
|
||
|
||
export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
|
||
|
||
// Clean, directly-askable example phrasings — for "recommended question"
|
||
// chips in the UI. Kept separate from SUPPORTED_QUESTIONS (which reads as
|
||
// documentation, "Orders in a batch — e.g. ...") since a chip needs to be
|
||
// the literal text to send, not a description of the intent.
|
||
export const EXAMPLE_QUESTIONS = [
|
||
'How many orders today?',
|
||
`${BATCHES[0].label} orders today`,
|
||
'How many riders are active?',
|
||
'Current hub status',
|
||
'How many vehicles are available?',
|
||
'Total revenue today',
|
||
'Orders today vs yesterday',
|
||
'How many consignments do we have?'
|
||
];
|
||
|
||
// Chips suggested right after a given intent answers — a light nudge toward
|
||
// a plausible next question, not a real "understands context" feature.
|
||
// Keyed by intent id so the AI panel can look these up off the last answer.
|
||
export const FOLLOW_UP_SUGGESTIONS = {
|
||
totalOrders: ['What about yesterday?', 'Revenue today'],
|
||
batchCount: ['What about yesterday?'],
|
||
statusBreakdown: ['Revenue today'],
|
||
revenueTotal: ['What about yesterday?', 'This week'],
|
||
weekOrders: ['Last week'],
|
||
riderCounts: ['Current hub status'],
|
||
hubStatus: ['How many vehicles are available?'],
|
||
vehicleStatus: ['Current hub status'],
|
||
tenantCount: ['What about yesterday?']
|
||
};
|
||
|
||
const matchAndRun = async (text) => {
|
||
for (const intent of INTENTS) {
|
||
const params = intent.match(text);
|
||
if (!params) continue;
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const result = await intent.run(params);
|
||
if (result) return { ...result, intentId: intent.id, params };
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// True when, after stripping filler words, the text is JUST a date/range
|
||
// phrase with no other recognisable domain keyword — "what about
|
||
// yesterday?" qualifies, "how many riders yesterday" does not (that's its
|
||
// own new question, not a follow-up on the same one).
|
||
const isBareDatePhrase = (text) => {
|
||
const stripped = text.replace(/\bwhat\s+about\b|\bhow\s+about\b|\band\b|\?/gi, '').trim();
|
||
if (!stripped) return false;
|
||
const looksLikeADate =
|
||
explicitDateFromWords(stripped) !== null ||
|
||
weekdayFromWords(stripped) !== null ||
|
||
rangeFromWords(stripped) !== null ||
|
||
/\byesterday\b|\btoday\b/i.test(stripped);
|
||
if (!looksLikeADate) return false;
|
||
return !/\brider|\btenant|\bhub|\bvehicle|\btripsheet|\bexception|\bcustomer|\bpricing|\bconsignment|\bpartner|\bcompetitor|\bcarrier/i.test(
|
||
stripped
|
||
);
|
||
};
|
||
|
||
// Follow-up context: "what about yesterday?" after an order-count question
|
||
// re-runs the SAME intent with just the date/range swapped, instead of
|
||
// requiring the whole question to be repeated. Only kicks in when the new
|
||
// text doesn't resolve to anything on its own (checked by the caller) AND
|
||
// reads as a bare date phrase AND there's a previous intent to re-run.
|
||
const rerunWithNewDate = async (lastIntentId, lastParams, text) => {
|
||
const intent = INTENTS_BY_ID[lastIntentId];
|
||
if (!intent || !lastParams) return null;
|
||
const range = rangeFromWords(text);
|
||
const newParams = { ...lastParams };
|
||
if (range) {
|
||
if ('start' in newParams) newParams.start = range.start;
|
||
if ('end' in newParams) newParams.end = range.end;
|
||
if ('rangeLabel' in newParams) newParams.rangeLabel = range.label;
|
||
if ('label' in newParams) newParams.label = range.label;
|
||
} else {
|
||
const day = dayFromWords(text);
|
||
if ('day' in newParams) newParams.day = day;
|
||
if ('start' in newParams) {
|
||
newParams.start = day;
|
||
newParams.end = day;
|
||
}
|
||
if ('rangeLabel' in newParams) newParams.rangeLabel = describeDay(day);
|
||
}
|
||
const result = await intent.run(newParams);
|
||
return result ? { ...result, intentId: intent.id, params: newParams } : null;
|
||
};
|
||
|
||
// "orders and revenue today" — answered as one combined response instead of
|
||
// only the first-matching intent, when the question plainly asks two things
|
||
// at once (segments joined by and/,/&). Each segment is matched
|
||
// independently through the exact same INTENTS catalog; a segment that
|
||
// doesn't resolve to anything is silently dropped rather than surfacing a
|
||
// partial/wrong result — same "no answer beats a guessed one" rule as
|
||
// everywhere else in this file.
|
||
const answerMultiPart = async (text) => {
|
||
const segments = text
|
||
.split(MULTI_SPLIT)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 2);
|
||
if (segments.length < 2) return null;
|
||
const results = [];
|
||
for (const segment of segments) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const r = await matchAndRun(segment);
|
||
if (r) results.push(r);
|
||
}
|
||
if (results.length < 2) return null; // not genuinely multi-part — let the normal single-intent path handle it
|
||
return {
|
||
headline: results.map((r) => r.headline).join(' '),
|
||
detail:
|
||
results
|
||
.map((r) => r.detail)
|
||
.filter(Boolean)
|
||
.join('\n\n') || undefined,
|
||
sourceCalls: results.flatMap((r) => r.sourceCalls || []),
|
||
intentId: 'multiPart',
|
||
params: { segments }
|
||
};
|
||
};
|
||
|
||
// Tries each intent in order; the first one whose `match` recognises the
|
||
// text AND whose `run` resolves to a real answer wins. Falls back to
|
||
// multi-part splitting, then to follow-up context (re-running the previous
|
||
// turn's intent with a new date) if `context` was passed in. Returns null
|
||
// if nothing matched (or every match failed to resolve) — the caller shows
|
||
// the "I can't answer that yet" fallback rather than a guess.
|
||
//
|
||
// `context` is optional: { lastIntentId, lastParams } from the previous
|
||
// turn's result, used only for the "what about yesterday?" follow-up path.
|
||
export async function answerQuestion(text, context = {}) {
|
||
const normalized = correctTypos(text);
|
||
|
||
// ---- Semantic routing (optional) ---------------------------------------
|
||
//
|
||
// Tried FIRST, because the regex catalog's weakness is vocabulary, not
|
||
// logic: "cancellation" not matching `cancel(led)?`, a bare reply matching
|
||
// nothing, "per day" being dropped. Retrieval fixes the matching problem
|
||
// without touching how an answer is produced — the intent's own run() still
|
||
// executes and every number still comes from a live API call.
|
||
//
|
||
// Returns null whenever the sidecar is absent, slow, or unsure, in which
|
||
// case the deterministic matcher below runs exactly as it does today. This
|
||
// path can only add coverage.
|
||
const routed = await routeQuestion(normalized);
|
||
if (isRouteTrustworthy(routed)) {
|
||
const intent = INTENTS_BY_ID[routed.intentId];
|
||
// The intent's own match() still extracts the slots — dates, statuses,
|
||
// tenants, riders. Retrieval decides WHICH question; parsing decides WITH
|
||
// WHAT. Embeddings are good at the former and unreliable at the latter.
|
||
const params = intent?.match(normalized);
|
||
if (intent && params) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const result = await intent.run(params);
|
||
if (result) return { ...result, intentId: intent.id, params, routing: routed };
|
||
}
|
||
}
|
||
|
||
// Tried BEFORE the single-intent pass: several intents match on a bare
|
||
// substring ("revenue" anywhere in the text) and their `run` never
|
||
// returns null, so on a combined question like "orders and revenue
|
||
// today" the broad intent would swallow the whole sentence and
|
||
// answerMultiPart would never get a turn. Only spend the extra fetches
|
||
// on this path when the text actually contains a connector; a real
|
||
// multi-part answer still requires >=2 segments to independently
|
||
// resolve, so a single-question false trigger ("service and delivery
|
||
// timing?") safely falls through to the normal single-intent match below.
|
||
if (MULTI_SPLIT.test(normalized)) {
|
||
const multi = await answerMultiPart(normalized);
|
||
if (multi) return multi;
|
||
}
|
||
|
||
const direct = await matchAndRun(normalized);
|
||
if (direct) return direct;
|
||
|
||
if (context.lastIntentId && isBareDatePhrase(normalized)) {
|
||
const followUp = await rerunWithNewDate(context.lastIntentId, context.lastParams, normalized);
|
||
if (followUp) return followUp;
|
||
}
|
||
|
||
// ---- Last resort: is this a question about how the console WORKS? -------
|
||
//
|
||
// Reached only when no intent produced an answer. "What is CityGate", "why
|
||
// does dispatch reconcile before commit", "what's the pagination cap" are
|
||
// real operator questions that no amount of API access can answer — they're
|
||
// answered by the documentation.
|
||
//
|
||
// Passages are returned VERBATIM with their source. There is no generation
|
||
// step: summarising would need a hosted model (CLAUDE.md §2) and would let a
|
||
// paraphrase drift from what the doc actually says. The operator reads the
|
||
// real words and can see which file they came from.
|
||
const docs = await askDocs(normalized);
|
||
if (docs?.chunks?.length) {
|
||
const best = docs.chunks[0];
|
||
return {
|
||
headline: best.heading || 'From the documentation',
|
||
detail: best.text,
|
||
list:
|
||
docs.chunks.length > 1
|
||
? {
|
||
title: 'Other passages',
|
||
numbered: false,
|
||
items: docs.chunks.slice(1).map((c) => ({ label: c.heading || c.source, meta: c.source }))
|
||
}
|
||
: undefined,
|
||
intentId: 'docsAnswer',
|
||
sourceCalls: docs.chunks.map((c) => ({
|
||
name: 'console_docs',
|
||
target: c.source,
|
||
status: 'complete',
|
||
stats: `similarity ${c.score}`
|
||
}))
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|