302 lines
13 KiB
JavaScript
302 lines
13 KiB
JavaScript
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);
|