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