updates on the ui changes and changed into doormile
This commit is contained in:
268
src/lib/assistant/orderFlow.js
Normal file
268
src/lib/assistant/orderFlow.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { getAdminPricing, getAdminCustomers, getTenantLocations } from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
|
||||
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',
|
||||
type: 'text',
|
||||
ask: 'Where is it being delivered? Give the full address.',
|
||||
// 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) => {
|
||||
const place = await geocodeAddress(String(t).trim()).catch(() => null);
|
||||
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
|
||||
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
|
||||
(place.address_components || []).forEach((c) => {
|
||||
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
|
||||
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
|
||||
});
|
||||
return {
|
||||
value: {
|
||||
...parts,
|
||||
deliverylatitude: place.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place.geometry?.location?.lng?.()
|
||||
}
|
||||
};
|
||||
},
|
||||
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);
|
||||
Reference in New Issue
Block a user