Files
doormilxpress_astryx/src/lib/assistant/orderFlow.js

302 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
import { getalltenants } from 'pages/api/api';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — conversational create-order ||============================== //
//
// One question at a time, mirroring createorder1.js's own field set and using a
// DROPDOWN wherever that page uses one — business location, customer, category,
// weight, service tier. Free text is only for things that genuinely are free
// text (a name, an address, a description).
//
// Two capabilities the customer flow didn't need:
//
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
// straight to picking from the real customer list, new asks for the fields.
// Steps carry a `when` predicate and are skipped when it's false.
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
// dropdown never shows a stale or invented list.
//
// The conversation is driven by the PANEL, which intercepts replies before the
// router ever sees them — see customerFlow.js for why that matters (a bare
// "8494948494" matches no intent and used to be discarded).
const PHONE_RE = /^\d{10}$/;
const digits = (t) => String(t || '').replace(/\D/g, '');
const isStaffLogin = () => {
const t = localStorage.getItem('tenantid');
return !t || t === '0';
};
// ---- steps ------------------------------------------------------------------
//
// type: 'select' → the panel renders a dropdown from `options(draft)`
// 'text' → answered through the composer
// when: omitted means always asked
export const ORDER_STEPS = [
{
id: 'tenantid',
type: 'select',
ask: 'Which tenant is this order for?',
// A client login already has its tenant; only Doormile staff choose.
when: () => isStaffLogin(),
options: async () => {
const tenants = (await getalltenants()) || [];
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
},
apply: (d, v) => ({ ...d, tenantid: v })
},
{
id: 'pickuplocationid',
type: 'select',
ask: 'Which business location is this picked up from?',
options: async (d) => {
const tid = d.tenantid || localStorage.getItem('tenantid');
const locations = (await getTenantLocations(tid)) || [];
return locations.map((l) => ({
value: String(l.locationid),
// The pincode is shown because it decides CityGate — a location outside
// the open cities will be refused server-side, and the operator should
// see that before choosing rather than after submitting.
label: `${l.locationname || l.address || 'Location'}${
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
}`,
record: l
}));
},
validate: (v, option) =>
cityGateFor(option?.record?.pincode)
? null
: `That location’s pincode (${
option?.record?.pincode || 'unknown'
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
},
{
id: 'customerMode',
type: 'select',
ask: 'Is this an existing customer, or a new one?',
options: async () => [
{ value: 'existing', label: 'Existing customer' },
{ value: 'new', label: 'New customer' }
],
apply: (d, v) => ({ ...d, customerMode: v })
},
{
id: 'existingCustomer',
type: 'select',
ask: 'Which customer?',
when: (d) => d.customerMode === 'existing',
options: async () => {
const customers = (await getAdminCustomers()) || [];
return customers
.filter((c) => c.phone)
.map((c) => ({
value: String(c.appcustomerid ?? c.id),
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
record: c
}));
},
// Picking an existing customer fills the name and phone, so the two
// free-text steps below are skipped by their own `when`.
apply: (d, v, option) => ({
...d,
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
customer_phone: digits(option?.record?.phone)
})
},
{
id: 'customer_name',
type: 'text',
ask: 'What’s the customer’s name?',
when: (d) => d.customerMode === 'new' && !d.customer_name,
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
},
{
id: 'customer_phone',
type: 'text',
ask: 'And their 10-digit mobile number?',
when: (d) => !d.customer_phone,
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
},
{
id: 'deliveryaddress',
// `address` renders a real search field with live suggestions instead of
// leaving the operator to type a full address into the chat box and hope
// the geocoder finds it. Typing still works — the resolve below falls back
// to geocoding free text — so this step degrades to its old behaviour
// wherever the field is not rendered.
type: 'address',
ask: 'Where is it being delivered? Start typing the area, street or pincode.',
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
// an address that can't be located is refused here rather than becoming a
// booking nothing can dispatch.
resolve: async (t, option) => {
// A place picked from the suggestion list is already resolved, with the
// coordinates the operator actually chose. Geocoding its label again
// can return a different place entirely.
const place = option?.place || (await geocodeAddress(String(t).trim()).catch(() => null));
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
// geocodingService.standardizePlace returns a FLAT place — formatted_address,
// city, postcode, latitude, longitude. This used to read Google's raw
// shape instead (`address_components`, `geometry.location.lat()`), and
// neither exists on what geocodeAddress actually returns: the forEach
// never ran and both coordinates came back undefined. Every order created
// through this flow was therefore missing its delivery coordinates — the
// exact thing the comment above says the optimiser routes on — and the
// pincode step below fired every time because nothing ever set it.
//
// The Google shape is still read as a fallback so a caller that hands in
// a raw Places result keeps working.
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined);
let lat = num(place.latitude);
let lng = num(place.longitude);
let city = place.city || '';
let pincode = place.postcode || '';
if (lat === undefined && place.geometry?.location) {
const loc = place.geometry.location;
lat = num(typeof loc.lat === 'function' ? loc.lat() : loc.lat);
lng = num(typeof loc.lng === 'function' ? loc.lng() : loc.lng);
}
if (!city || !pincode) {
(place.address_components || []).forEach((c) => {
if (!city && (c.types || []).includes('locality')) city = c.long_name;
if (!pincode && (c.types || []).includes('postal_code')) pincode = c.long_name;
});
}
const value = { deliveryaddress: place.formatted_address || place.name || String(t).trim() };
if (city) value.deliverycity = city;
if (pincode) value.deliverypincode = pincode;
if (lat !== undefined && lng !== undefined) {
value.deliverylatitude = lat;
value.deliverylongitude = lng;
}
return { value };
},
apply: (d, v) => ({ ...d, ...v })
},
{
id: 'deliverypincode',
type: 'text',
ask: 'What’s the delivery pincode?',
when: (d) => !d.deliverypincode,
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
},
{
id: 'service_option',
type: 'select',
ask: 'Which service level?',
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
apply: (d, v) => ({ ...d, service_option: v })
},
{
id: 'itemcategory',
type: 'select',
ask: 'What kind of parcel is it?',
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
apply: (d, v) => ({ ...d, itemcategory: v })
},
{
id: 'weight',
type: 'select',
ask: 'Roughly how heavy?',
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
apply: (d, v) => ({ ...d, weight: v })
},
{
id: 'itemdescription',
type: 'text',
ask: 'Briefly, what’s inside?',
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
},
{
id: 'quantity',
type: 'select',
ask: 'How many parcels?',
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
},
{
id: 'finalprice',
type: 'text',
ask: 'What should the price be? Enter the amount in ₹.',
// `auto` answers a step from real data and only falls back to asking. The
// quote is stashed either way so the confirmation can show the distance it
// measured, and say why it couldn’t price when it couldn’t.
auto: async (d) => {
const quote = await priceOrder(d);
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
return {
patch: { __quote: quote },
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
};
},
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
apply: (d, t) => ({ ...d, finalprice: Number(t) })
}
];
// Mirrors createorder1.js's own lists so the bot offers the same choices.
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
// ---- pricing ----------------------------------------------------------------
//
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
// this tenant's own pricing row. Quoted, never invented — if no pricing row
// matches, the operator is asked for the amount rather than shown a zero.
export const priceOrder = async (draft) => {
const tid = draft.tenantid || localStorage.getItem('tenantid');
const pricing = (await getAdminPricing()) || [];
// tenantid is numeric on the pricing row and a string from localStorage — a
// strict comparison here silently priced every order at zero once before.
const match = pricing.find((p) => String(p.tenantid) === String(tid));
const pickup = draft.__pickup;
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
const km = await calculateDrivingDistance(
{ latitude: pickup.latitude, longitude: pickup.longitude },
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
).catch(() => null);
if (km == null) return { error: 'could not measure the distance' };
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
return {
km,
durationMin: getLastRouteDurationMin(),
basePrice: match.baseprice,
total: Number(Number(total).toFixed(2))
};
};
// ---- engine -----------------------------------------------------------------
//
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
// These wrappers keep the order-specific names the panel and the tests use.
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
export const startOrderFlow = () => {
// A client login already belongs to a tenant, so its step is skipped — but
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
// draft is what makes the skip safe.
const tid = localStorage.getItem('tenantid');
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
};
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);