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