From 51667d1c9fc0ae8a9c5fb2029f1a56ac8fbce998 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 18 Aug 2026 15:54:01 +0530 Subject: [PATCH] Doormile AI: make the numbers trustworthy, then composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-layer work on the assistant, in the order it mattered. Correctness first: - getBookingsPage keeps the envelope's `total`/`page`. getBookings threw them away, so no caller could tell a full result from a truncated one. - Every booking read now drains pages up to a budget instead of taking page one at the API's 1000-row cap. Past 1000 lifetime bookings, every count and sum in this file silently under-reported while the source line beside it still read "complete". Row order is detected per call, so a backend that stops returning newest-first degrades to a full scan rather than to a wrong answer. - A capped scan now says "At least N", appends what it scanned versus the total, and marks its source call as failed. - Revenue excludes cancelled orders, sums every service option rather than the first, and is labelled estimated — it is a quote, not settled money. - A strong order reference that isn't found is answered "I couldn't find it" instead of falling through to a broader intent, which used to answer "142 orders created today" to a question about one order. Then agreement with the Orders page: - utils/orderStatusGroups.js is now the single definition of which raw booking enums make up each status; orders.js builds its tabs from it and the assistant matches against it. The assistant had been using api.js's Deliveries taxonomy, which keeps miler_assigned on `pending`, so the Orders page showed 19 Assigned while the bot answered 0. The two taxonomies stay separate on purpose — Orders tracks the operator's action, Deliveries tracks the rider's. - A status question with no date named is no longer scoped to today. "How many orders are assigned" describes the queue right now, which is what the Orders page's tabs show; they apply no date filter either. - The Orders header said "Today" over counts that were never date-filtered. Corrected the label rather than adding a filter, since filtering would hide currently-visible rows — a product decision, not a bug fix. Then capability: - delayedOrders answers "which orders are delayed" from the promised delivery time. deliveries.js and Dispatch.js both rejected that field for batch bucketing because an ETA is not the wave an order belongs to; that reasoning does not carry over to lateness, where a promise that never gets re-stamped is exactly the right baseline. If no order carries an ETA it says so rather than reporting a reassuring "0 delayed". - orderQuery composes status x batch x tenant x rider, plus rankings. It claims a question only when two or more of those are present, so single-dimension questions keep their proven intents. A date is not counted as a dimension — counting it re-routed four working questions. - An unresolved tenant/rider name falls through instead of having its filter silently dropped, which was the original defect. - orderLookup resolves the rider's name and reads the tracking trail defensively, since that endpoint's response shape is undocumented. Co-Authored-By: Claude Opus 5 --- src/pages/api/doormileApi.js | 27 + src/pages/nearle/assistant/intents.js | 1741 +++++++++++++++++++++++-- src/pages/nearle/orders/orders.js | 31 +- src/utils/orderStatusGroups.js | 67 + 4 files changed, 1754 insertions(+), 112 deletions(-) create mode 100644 src/utils/orderStatusGroups.js diff --git a/src/pages/api/doormileApi.js b/src/pages/api/doormileApi.js index 78c5546..37de9d2 100644 --- a/src/pages/api/doormileApi.js +++ b/src/pages/api/doormileApi.js @@ -323,6 +323,33 @@ export const getBookings = async (pageno, pagesize) => { return response.data.data; }; +// Same request as getBookings, but keeps the envelope's `total`/`page` instead +// of throwing them away. +// +// getBookings returns `response.data.data` — just the rows — which means no +// caller can tell a full result from a truncated one. `pagesize` is capped at +// 1000 server-side (express-console-api.md, "Conventions": default 500, cap +// 1000), so any account with more than 1000 bookings silently gets a partial +// list from a single call. That's tolerable for a paginated table (it fetches +// the next page as you scroll) but NOT for anything that counts or sums, which +// would report a confidently wrong number. +// +// Kept as a separate export rather than changing getBookings' return shape: +// four callers (fetchDeliveries, fetchCountAPI, orders.js, the assistant's own +// per-order lookup) already destructure it as a bare array. +export const getBookingsPage = async (pageno, pagesize) => { + const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`); + const body = response.data || {}; + return { + rows: body.data || [], + // `total` is documented as present on list responses; fall back to the row + // count so a backend that omits it degrades to "this is everything" rather + // than to NaN-driven pagination. + total: Number.isFinite(Number(body.total)) ? Number(body.total) : (body.data || []).length, + page: Number(body.page) || pageno + }; +}; + export const createExpressBooking = async (data) => { // tenantid is forced to the caller's own tenant server-side on client logins. // CityGate applies: pickup pincode prefix must be an open city (641/600/560/500/629). diff --git a/src/pages/nearle/assistant/intents.js b/src/pages/nearle/assistant/intents.js index ce442ee..28d9125 100644 --- a/src/pages/nearle/assistant/intents.js +++ b/src/pages/nearle/assistant/intents.js @@ -1,18 +1,39 @@ import dayjs from 'dayjs'; -import { getBookings } from 'pages/api/doormileApi'; -import { getalltenants, getallridersummary, mapBookingStatusToDeliveryStatus } from 'pages/api/api'; +import { + getBookingsPage, + getHubs, + getVehicles, + getTripsheets, + getExceptions, + getAppUsers, + getAdminCustomers, + getAdminPricing, + getMilers, + getConsignments, + getPartners, + getCompetitorBranches, + getCarrierPricing, + getMilerSummary, + getBookingTrack +} from 'pages/api/doormileApi'; +import { getalltenants, getallridersummary } from 'pages/api/api'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; -import { getRowBatchId, getBatchLabel } from 'utils/batchBucket'; +import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket'; +import { STATUS } from 'themes/dt/tokens'; +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): every -// answer comes from a real API call against the same functions the rest of -// the console uses, never from 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 so an operator can see exactly what was queried. +// 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 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 @@ -23,13 +44,188 @@ import { getRowBatchId, getBatchLabel } from 'utils/batchBucket'; // 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' +]; + +const correctTypos = (text) => + text + .split(/\b/) + .map((token) => { + const word = token.toLowerCase(); + 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 to ". 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) => { @@ -39,31 +235,169 @@ const batchFromWords = (text) => { 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/i.test(text)) return 'pending'; - if (/\bcancel(led)?\b/i.test(text)) return 'cancelled'; - if (/\baccept(ed)?\b/i.test(text)) return 'accepted'; - if (/\bactive\b|\bin[- ]?transit\b/i.test(text)) return 'active'; - // Checked after the more specific ones above — "delivered" would also - // false-match "undelivered" without the negative lookahead-ish guard. - if (/\bdeliver(ed)?\b/i.test(text) && !/\bundeliver/i.test(text)) return 'delivered'; + if (/\bpending\b|\bnot\s*assigned\b|\bunassigned\b|\bawaiting\b/i.test(text)) return 'pending'; + if (/\bcancel(led)?\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; }; -// Bulk-fetch bookings and keep only the ones created on `day` — same -// client-side date filter shape as fetchDeliveries' `inRange` in api.js. -const fetchBookingsForDay = async (day) => { - const bookings = (await getBookings(1, BULK_PAGESIZE)) || []; - return bookings.filter((b) => { - const d = parseDoormileTimestamp(b.createdat); - return d.isValid() && d.format('YYYY-MM-DD') === day; - }); +// 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. +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 = mapBookingStatusToDeliveryStatus(b.status); + const s = ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] || groupForBookingStatus(b.status); counts[s] = (counts[s] || 0) + 1; }); return Object.entries(counts) @@ -71,6 +405,57 @@ const summarizeStatuses = (rows) => { .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(); @@ -79,94 +464,509 @@ const resolveTenant = async (text) => { 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]; +}; + +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; + +// 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 = [ { - id: 'batchCount', - label: 'Orders in a batch — e.g. "morning batch orders today"', + id: 'orderLookup', + label: 'Status of a specific order — e.g. "status of order #1234"', match: (text) => { - const batch = batchFromWords(text); - if (!batch) return null; - return { batch, day: dayFromWords(text) }; + if (!/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null; + const ref = orderIdFromWords(text); + if (!ref) return null; + return { orderId: ref.id, strong: ref.strong }; }, - run: async ({ batch, day }) => { - const rows = await fetchBookingsForDay(day); - const matched = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch); + 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. + const [milers, track] = await Promise.all([ + found.assignedmileruserid ? getMilers().catch(() => null) : Promise.resolve(null), + getBookingTrack(found.bookingid).catch(() => null) + ]); + const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null; + + // GET /admin/bookings/:id/track has no documented response shape + // (express-console-api.md lists it as written-but-unproven), so read it + // defensively: report only what is unambiguously there, and stay silent + // rather than rendering fields that may not mean what their names imply. + const steps = Array.isArray(track) ? track : null; + const lastStep = steps && steps.length ? steps[steps.length - 1] : 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' + }); + } + extraCalls.push( + steps + ? { + name: 'getBookingTrack', + target: `/admin/bookings/${found.bookingid}/track`, + status: 'complete', + stats: `${steps.length} events` + } + : { + name: 'getBookingTrack', + target: `/admin/bookings/${found.bookingid}/track`, + status: 'error', + errorMessage: 'No tracking data returned' + } + ); + + const riderLine = found.assignedmileruserid + ? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${ + rider?.phone ? ` · ${rider.phone}` : '' + }` + : 'Not yet assigned to a rider.'; + return { - headline: `${matched.length} order${matched.length === 1 ? '' : 's'} in the ${getBatchLabel(batch)} ${describeDay(day)}.`, - detail: matched.length - ? `Order IDs: ${matched.slice(0, 10).map(bookingLabel).join(', ')}${ - matched.length > 10 ? ` …and ${matched.length - 10} more` : '' - }` - : 'No orders fall in this batch for that day.', - sourceCalls: [ - { - name: 'getBookings', - target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`, - status: 'complete', - stats: `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}` - } + headline: `Order ${bookingLabel(found)} is ${status}.`, + detail: [ + riderLine, + found.pickupaddress ? `Pickup: ${found.pickupaddress}` : null, + found.deliveryaddress ? `Drop: ${found.deliveryaddress}` : null, + etaOf(found) ? `Promised by ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}` : null, + steps && steps.length + ? `${steps.length} tracking event${steps.length === 1 ? '' : 's'}${lastStep?.status ? `, latest: ${lastStep.status}` : ''}` + : null ] + .filter(Boolean) + .join('\n'), + sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls] }; } }, { - id: 'statusBreakdown', - label: 'Orders by status — e.g. "how many delivered orders today"', + // 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 { + headline: `${found.displayname || found.name} — ${found.status || 'status unknown'}.`, + detail: + [found.phonenumber ? `Phone: ${found.phonenumber}` : null, found.vehicletype ? `Vehicle: ${found.vehicletype}` : null] + .filter(Boolean) + .join('\n') || undefined, + sourceCalls: [{ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: `matched "${name}"` }] + }; + } + }, + { + // ---- 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); - if (!status) return null; - return { status, day: dayFromWords(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, day }) => { - const rows = await fetchBookingsForDay(day); - const matched = rows.filter((b) => mapBookingStatusToDeliveryStatus(b.status) === status); - return { - headline: `${matched.length} ${status} order${matched.length === 1 ? '' : 's'} ${describeDay(day)}.`, - detail: `Out of ${rows.length} orders created ${describeDay(day)}.`, - sourceCalls: [ - { - name: 'getBookings', - target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`, - status: 'complete', - stats: `${rows.length} total → ${matched.length} ${status}` + 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}`; } - ] - }; - } - }, - { - id: 'tenantCount', - label: 'Orders for a tenant — e.g. "orders for today"', - match: (text) => { - 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 rows = await fetchBookingsForDay(day); - const matched = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid)); - return { - headline: `${matched.length} order${matched.length === 1 ? '' : 's'} for ${tenant.tenantname} ${describeDay(day)}.`, - detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.`, - sourceCalls: [ - { name: 'getalltenants', target: '/admin/tenants', status: 'complete' }, - { - name: 'getBookings', - target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`, - status: 'complete', - stats: `${rows.length} total → ${matched.length} for ${tenant.tenantname}` - } - ] + 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), + detail: + (rows.length + ? `Order IDs: ${rows.slice(0, 10).map(bookingLabel).join(', ')}${rows.length > 10 ? ` …and ${rows.length - 10} more` : ''}` + : '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) => (/\brider(s)?\b/i.test(text) ? {} : null), + match: (text) => (mentionsRiders(text) ? {} : null), run: async () => { const summary = await getallridersummary(); return { @@ -178,38 +978,765 @@ const INTENTS = [ }; } }, + { + 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.`, + detail: tenants.length + ? `${tenants + .slice(0, 10) + .map((t) => t.tenantname) + .filter(Boolean) + .join(', ')}${tenants.length > 10 ? ` …and ${tenants.length - 10} more` : ''}` + : 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'}` }, + 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 + ? `Late: ${late + .slice(0, 8) + .map((b) => `${bookingLabel(b)} (${formatLateness(b, now)})`) + .join(', ')}${late.length > 8 ? ` …and ${late.length - 8} more` : ''}` + : '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.`, + detail: hubs.length + ? hubs + .slice(0, 10) + .map((h) => `${h.hubname || `Hub #${h.hubid}`} — ${h.status || 'unknown'}`) + .join('\n') + (hubs.length > 10 ? `\n…and ${hubs.length - 10} more` : '') + : undefined, + 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)}` }, + detail: + (matched.length + ? `Order IDs: ${matched.slice(0, 10).map(bookingLabel).join(', ')}${ + matched.length > 10 ? ` …and ${matched.length - 10} more` : '' + }` + : '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 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: 'Today\'s total order count — e.g. "how many orders today"', - match: (text) => (/\border(s)?\b/i.test(text) ? { day: dayFromWords(text) } : null), + 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 rows = await fetchBookingsForDay(day); + const scan = await fetchBookingsForDay(day); + const rows = scan.rows; return { - headline: `${rows.length} order${rows.length === 1 ? '' : 's'} created ${describeDay(day)}.`, - detail: rows.length ? `Statuses: ${summarizeStatuses(rows)}` : undefined, - sourceCalls: [ - { name: 'getBookings', target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`, status: 'complete', stats: `${rows.length} matched` } - ] + 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`)] }; } } ]; +const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i])); + export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label); -// Tries each intent in order; the first one whose `match` recognises the -// text AND whose `run` resolves to a real answer wins. 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. -export async function answerQuestion(text) { +// 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; + 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); + + // 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; + } + return null; } diff --git a/src/pages/nearle/orders/orders.js b/src/pages/nearle/orders/orders.js index fc10fae..4737fcb 100644 --- a/src/pages/nearle/orders/orders.js +++ b/src/pages/nearle/orders/orders.js @@ -71,6 +71,7 @@ import { useNavigate } from 'react-router-dom'; import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api'; import { getBookings, getAdminCustomers, batchAssignBookings, getMilers } from 'pages/api/doormileApi'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; +import { statusesInGroup } from 'utils/orderStatusGroups'; import { DT, STATUS, tint } from 'themes/dt/tokens'; import { TableScroll } from 'themes/dt/primitives'; @@ -104,18 +105,30 @@ import { TableScroll } from 'themes/dt/primitives'; // match set; each tab's singular `status` stays the tab's // primary/representative value for `currentStatus`, the React key, and the // few `currentStatus === 'pending_pickup'` checks elsewhere in this file. +// +// The `statuses` match sets now come from utils/orderStatusGroups.js so the +// Doormile AI assistant counts "assigned orders" with the exact same set this +// page does — it previously had its own idea and disagreed with these tabs. +// Labels, colours and icons stay here; only the match sets are shared. const ORDERS_STATUS_TABS = [ - { idx: 0, status: 'pending_pickup', statuses: ['pending_pickup'], label: 'Pending', color: STATUS.pending, icon: MdHourglassEmpty }, + { + idx: 0, + status: 'pending_pickup', + statuses: statusesInGroup('pending'), + label: 'Pending', + color: STATUS.pending, + icon: MdHourglassEmpty + }, { idx: 1, status: 'converted_to_consignment', - statuses: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'], + statuses: statusesInGroup('assigned'), label: 'Assigned', color: STATUS.accepted, icon: MdCheckCircle }, - { idx: 2, status: 'delivered', statuses: ['delivered'], label: 'Delivered', color: STATUS.delivered, icon: MdCheckCircle }, - { idx: 3, status: 'cancelled', statuses: ['cancelled'], label: 'Cancelled', color: STATUS.cancelled, icon: MdCancel } + { idx: 2, status: 'delivered', statuses: statusesInGroup('delivered'), label: 'Delivered', color: STATUS.delivered, icon: MdCheckCircle }, + { idx: 3, status: 'cancelled', statuses: statusesInGroup('cancelled'), label: 'Cancelled', color: STATUS.cancelled, icon: MdCancel } ]; // TanStack Table v9 registers features explicitly — only what is listed here @@ -323,7 +336,15 @@ const Orders = () => { // No date-filter UI on this page — the toolbar is search-only. These stay // fixed to "today" (still read by percentageData's query key and by the // solver hand-off's deliverydate/assigntime below). - const datestatus = 'Today'; + // Was hardcoded 'Today', which was simply untrue: the tab counts and the + // table below come from `allBookings` (getBookings with no date parameter), + // so this header has always described an all-time list as today's. That + // mislabel is what made the assistant look wrong — it answered for today, + // the page showed all-time, and the two numbers disagreed with no visible + // reason. Changed the label rather than adding a date filter: filtering + // would hide currently-visible rows, which is a product decision, not a + // bug fix. + const datestatus = 'All time'; const startdate = dayjs().format('YYYY-MM-DD'); const enddate = dayjs().format('YYYY-MM-DD'); const [searchword, setSearchword] = useState(''); diff --git a/src/utils/orderStatusGroups.js b/src/utils/orderStatusGroups.js new file mode 100644 index 0000000..85fed91 --- /dev/null +++ b/src/utils/orderStatusGroups.js @@ -0,0 +1,67 @@ +// ============================================================================ +// Order status groups — which RAW `GET /admin/bookings` enums make up each +// operator-facing status the Orders page shows as a tab. +// +// Extracted from orders.js's ORDERS_STATUS_TABS so the Doormile AI assistant +// can answer "how many assigned orders" with the SAME match set the Orders +// page counts, instead of growing a second definition that drifts. Same reason +// utils/batchBucket.js exists. +// +// ⚠ This is deliberately NOT the same taxonomy as api.js's +// BOOKING_STATUS_TO_DELIVERY_STATUS, and the two must not be merged: +// +// • Orders page (this file) — tracks the OPERATOR's workflow. "Assigned" +// means the operator picked a rider, the moment assign-miler succeeds, so +// miler_assigned counts as Assigned. +// • Deliveries page (api.js) — tracks the RIDER's engagement. Its "Accepted" +// means the rider accepted, a deliberately later and narrower bar, so it +// keeps miler_assigned on 'pending'. +// +// That split is explicit product direction (see the long comment above +// ORDERS_STATUS_TABS in orders.js — it was unified once and reverted). The +// assistant answers order-status questions with the ORDERS taxonomy, because +// that is the screen an operator is comparing its answers against. +// ============================================================================ + +export const ORDER_STATUS_GROUPS = { + pending: ['pending_pickup'], + assigned: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'], + // The Orders page has no tab for this one — a booking that is out for + // delivery has left the operator's queue. The assistant still needs it so + // "how many orders are in transit" resolves, and so statusBreakdown's + // totals account for every row rather than silently dropping some. + active: ['out_for_delivery'], + delivered: ['delivered'], + cancelled: ['cancelled'] +}; + +export const ORDER_STATUS_LABELS = { + pending: 'Pending', + assigned: 'Assigned', + active: 'Out for delivery', + delivered: 'Delivered', + cancelled: 'Cancelled' +}; + +// Display order — matches the lifecycle, and the order the Orders page's tabs +// appear in. +export const ORDER_STATUS_ORDER = ['pending', 'assigned', 'active', 'delivered', 'cancelled']; + +const RAW_TO_GROUP = Object.entries(ORDER_STATUS_GROUPS).reduce((acc, [group, raws]) => { + raws.forEach((raw) => { + acc[raw] = group; + }); + return acc; +}, {}); + +export const statusesInGroup = (group) => ORDER_STATUS_GROUPS[group] || []; + +// Raw booking enum → group key. Returns the lowercased raw value itself for an +// enum this map has never seen, so an unmapped backend status stays visible in +// a breakdown rather than vanishing from the totals. +export const groupForBookingStatus = (raw) => { + const key = String(raw || '').toLowerCase(); + return RAW_TO_GROUP[key] || key; +}; + +export const isInGroup = (raw, group) => statusesInGroup(group).includes(String(raw || '').toLowerCase());