Doormile AI: make the numbers trustworthy, then composable
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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('');
|
||||
|
||||
67
src/utils/orderStatusGroups.js
Normal file
67
src/utils/orderStatusGroups.js
Normal file
@@ -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());
|
||||
Reference in New Issue
Block a user