diff --git a/.env.example b/.env.example index 06b71c7..a2b5d76 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,8 @@ # OSRM routing, used to price an order by real driving distance. Falls back to # the public demo server, which is rate-limited and not for production traffic. VITE_OSRM_URL=https://router.project-osrm.org + +# Geocoding & Address Suggestions +# The console uses high-speed Photon & OSM by default (no key required). +# To enable Ola Maps Places Autocomplete for pinpoint Indian addresses, provide an API key: +# VITE_OLA_MAPS_API_KEY=your_ola_maps_api_key_here diff --git a/scripts/makeLogisticsTestSheet.cjs b/scripts/makeLogisticsTestSheet.cjs new file mode 100644 index 0000000..bcdb34a --- /dev/null +++ b/scripts/makeLogisticsTestSheet.cjs @@ -0,0 +1,215 @@ +/** + * Build the logistics bulk-upload test sheet. + * + * node scripts/makeLogisticsTestSheet.cjs + * + * The tenant's own export (tests/fixtures/jayanthi-kitchen-route.xls) is 13 + * lunch drops inside Coimbatore. Every one of them is hyperlocal, so uploading + * it exercises none of the base-handover path — no parcel in that file is ever + * routed through a base, and `next_action` never leaves `deliver`. + * + * This sheet keeps that file's exact column titles, so it travels through the + * same header map and the same page code, and changes only the one thing that + * decides routing: WHERE THE PARCEL IS GOING. + * + * The rule it is built against (backend, isHyperlocalBooking): + * + * • both pincodes present → same first 3 digits is hyperlocal, different is + * routed through a base. The prefix decides + * outright; distance does not get a vote. + * • a pincode missing → fall back to straight-line distance, ≤30 km is + * hyperlocal. + * • neither resolvable → routed through a base, which is the safe answer. + * + * Two columns are added that the tenant's file does not carry — Receiver + * Pincode and Receiver City. Both are already in SHEET_HEADER_MAP + * (receiverpincode → postcode, receivercity → city), so this is not a new + * format: it is the same format with columns the map already knows filled in. + * Without a pincode every row would land on the distance fallback, and the + * prefix rule — the one that actually decides intercity — would never run. + */ +const fs = require('node:fs'); +const path = require('node:path'); +const XLSX = require('xlsx'); + +const SENDER = { + name: 'Jayanthi', + phone: 7708733769, + address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025' +}; + +const PICKUP_DATE = 'Sep 02, 2026'; + +/** + * expect: what the backend should decide for the row, and why. Not a column — + * it is carried here so the fixture and the test assertions cannot drift, and + * it is written into a "Notes" column on the sheet so an operator running this + * by hand can see what they are meant to be looking at. + */ +const ROWS = [ + // ---- Customer → Base. Different postal area, so the rider collects and + // carries it to a base instead of to the receiver. This is the whole + // point of the file. + { + name: 'Suresh Kumar', phone: 9840112233, alt: 9840112234, + address: 'Flat 3B, 21 NSC Bose Road, Parrys, Chennai', city: 'Chennai', pincode: '600001', + lat: 13.091, lng: 80.285, item: 'Documents envelope', qty: 1, cash: 0, + expect: 'base handover — Coimbatore 641 to Chennai 600' + }, + { + name: 'Priya Raghavan', phone: 9840223344, alt: 9840223345, + address: '14 Cheran Street, Raja Annamalai Puram, Chennai', city: 'Chennai', pincode: '600028', + lat: 13.018, lng: 80.256, item: 'Spare parts carton', qty: 2, cash: 0, + expect: 'base handover — Coimbatore 641 to Chennai 600' + }, + { + name: 'Anil Reddy', phone: 9848334455, alt: 9848334456, + address: 'Plot 42, Gachibowli, Hyderabad', city: 'Hyderabad', pincode: '500081', + lat: 17.44, lng: 78.3489, item: 'Textile samples', qty: 3, cash: 0, + expect: 'base handover — interstate, Coimbatore to Hyderabad' + }, + { + name: 'Meera Krishnan', phone: 9848445566, alt: 9848445567, + address: 'Tower B, Financial District, Nanakramguda, Hyderabad', city: 'Hyderabad', pincode: '500032', + lat: 17.4156, lng: 78.3378, item: 'Legal documents', qty: 1, cash: 450, + expect: 'base handover with COD — cash must survive the base leg' + }, + { + name: 'Rahul Menon', phone: 9880556677, alt: 9880556678, + address: '9 Brigade Road, MG Road, Bengaluru', city: 'Bengaluru', pincode: '560001', + lat: 12.975, lng: 77.606, item: 'Electronics accessories', qty: 2, cash: 0, + expect: 'base handover — interstate, Coimbatore to Bengaluru' + }, + { + name: 'Divya Nair', phone: 9880667788, alt: 9880667789, + address: 'Prestige Shantiniketan, Whitefield, Bengaluru', city: 'Bengaluru', pincode: '560066', + lat: 12.9698, lng: 77.75, item: 'Gift hamper', qty: 1, cash: 1200, + expect: 'base handover with COD' + }, + { + name: 'Karthik Subramani', phone: 9843778899, alt: 9843778890, + address: '5 East Veli Street, Madurai', city: 'Madurai', pincode: '625001', + lat: 9.9195, lng: 78.119, item: 'Temple prasadam box', qty: 1, cash: 0, + expect: 'base handover — same state, different postal area still routes via a base' + }, + { + name: 'Lakshmi Devi', phone: 9842889900, alt: 9842889901, + address: '18 Cherry Road, Salem', city: 'Salem', pincode: '636001', + lat: 11.664, lng: 78.146, item: 'Handloom bundle', qty: 2, cash: 0, + expect: 'base handover — same state, different postal area' + }, + + // ---- Customer → Customer. Same postal area, so the collecting rider carries + // it straight to the receiver and no base is involved. These rows are + // the control group: if one of them routes to a base, the prefix rule + // has broken. + { + name: 'Ganesh Iyer', phone: 9994001122, alt: 9994001123, + address: '22 Avinashi Road, Peelamedu, Coimbatore', city: 'Coimbatore', pincode: '641004', + lat: 11.029, lng: 76.993, item: 'Lunch box', qty: 1, cash: 0, + expect: 'direct to customer — same 641 area' + }, + { + name: 'Revathi Balaji', phone: 9994112233, alt: 9994112234, + address: '7 Cross Cut Road, Gandhipuram, Coimbatore', city: 'Coimbatore', pincode: '641012', + lat: 11.018, lng: 76.966, item: 'Lunch box', qty: 2, cash: 260, + expect: 'direct to customer with COD — same 641 area' + }, + { + name: 'Vignesh Murugan', phone: 9994223344, alt: 9994223345, + address: '3/74 Nethaji Street, Edayarpalayam, Coimbatore', city: 'Coimbatore', pincode: '641025', + lat: 11.008, lng: 76.928, item: 'Lunch box', qty: 1, cash: 0, + expect: 'direct to customer — receiver shares the sender pincode' + }, + { + name: 'Anitha Selvam', phone: 9994334455, alt: 9994334456, + address: '11 DB Road, Saibaba Colony, Coimbatore', city: 'Coimbatore', pincode: '641038', + lat: 11.023, lng: 76.945, item: 'Bakery order', qty: 3, cash: 0, + expect: 'direct to customer — same 641 area' + }, + + // ---- The fallback rule. A pincode is missing, so the decision falls to the + // straight-line distance instead. Both outcomes are represented, because + // a fallback that only ever answers one way is not being tested. + { + name: 'Mohan Das', phone: 9995445566, alt: 9995445567, + address: 'Near Hope College signal, Coimbatore', city: 'Coimbatore', pincode: '', + lat: 11.05, lng: 77.01, item: 'Stationery pack', qty: 1, cash: 0, + expect: 'direct to customer — no pincode, but ~8 km falls inside the 30 km fallback' + }, + { + name: 'Sridhar Venkat', phone: 9995556677, alt: 9995556678, + address: 'Opposite Central Station, Chennai', city: 'Chennai', pincode: '', + lat: 13.0827, lng: 80.2707, item: 'Machine component', qty: 1, cash: 0, + expect: 'base handover — no pincode, but ~430 km is far outside the fallback' + }, + { + name: 'Bhavani Shankar', phone: 9995667788, alt: 9995667789, + address: 'T Nagar, Chennai', city: 'Chennai', pincode: '64', + lat: 13.06, lng: 80.24, item: 'Fabric roll', qty: 1, cash: 0, + expect: 'base handover — a 2-digit pincode is unusable, so distance decides' + }, + + // ---- The case that proves the prefix rule outranks distance. Pollachi is + // ~40 km from Coimbatore — closer than plenty of runs the console treats + // as local — but 642 is a different postal area, so it goes via a base. + { + name: 'Ramesh Palanisamy', phone: 9995778899, alt: 9995778890, + address: '2 Mahalingapuram Road, Pollachi', city: 'Pollachi', pincode: '642001', + lat: 10.658, lng: 77.008, item: 'Agricultural samples', qty: 2, cash: 0, + expect: 'base handover — nearby, but 642 is a different area and the prefix decides' + } +]; + +const HEADERS = [ + 'Pickup Date (YYYY-MMM-DD )', + 'Sender Name*', + 'Sender Phone*', + 'Sender Address*', + 'Receiver Name*', + 'Receiver Phone', + 'Receiver Alternate Phone*', + 'Receiver Full Address', + 'Receiver City', + 'Receiver Pincode', + 'Receiver Latitude', + 'Receiver Longitude', + 'Item Description*', + 'Quantity', + 'Collect Cash', + 'Expected Routing (not read by the app)' +]; + +const sheetRows = ROWS.map((r) => ({ + 'Pickup Date (YYYY-MMM-DD )': PICKUP_DATE, + 'Sender Name*': SENDER.name, + 'Sender Phone*': SENDER.phone, + 'Sender Address*': SENDER.address, + 'Receiver Name*': r.name, + 'Receiver Phone': r.phone, + 'Receiver Alternate Phone*': r.alt, + 'Receiver Full Address': r.address, + 'Receiver City': r.city, + 'Receiver Pincode': r.pincode, + 'Receiver Latitude': r.lat, + 'Receiver Longitude': r.lng, + 'Item Description*': r.item, + Quantity: r.qty, + 'Collect Cash': r.cash, + 'Expected Routing (not read by the app)': r.expect +})); + +const ws = XLSX.utils.json_to_sheet(sheetRows, { header: HEADERS }); +ws['!cols'] = HEADERS.map((h) => ({ wch: Math.min(46, Math.max(14, h.length + 2)) })); + +const wb = XLSX.utils.book_new(); +XLSX.utils.book_append_sheet(wb, ws, 'Doormile_Logistics_Test'); + +const targets = process.argv.slice(2); +if (targets.length === 0) targets.push(path.join(__dirname, '..', 'tests', 'fixtures', 'doormile-logistics-test.xlsx')); + +targets.forEach((out) => { + fs.mkdirSync(path.dirname(out), { recursive: true }); + XLSX.writeFile(wb, out); + console.log(`wrote ${sheetRows.length} rows → ${out}`); +}); diff --git a/src/App.jsx b/src/App.jsx index e588ad7..5fcc03b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -19,6 +19,7 @@ const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx')); const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx')); const Orders = lazy(() => import('@/pages/doormile/orders/Orders')); +const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings')); const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview')); const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder')); const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders')); @@ -93,6 +94,8 @@ const AuthenticatedApp = () => { } /> } /> + } /> + } /> } /> diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index 7f2b5fb..042c0b9 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -404,9 +404,12 @@ export const getBookings = async (pageno, pagesize) => { // // 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 +// 100 server-side, and the default is 20 — NOT "default 500, cap 1000", which +// is what express-console-api.md said until it was corrected. Asking for 1000 +// returns 100 rows and an envelope whose own `pagesize` reads 100, so the +// under-fetch is silent unless you check it. Any account with more than 100 +// bookings therefore 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. // diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js index cae2ecd..0f600cc 100644 --- a/src/api/doormile/queries.js +++ b/src/api/doormile/queries.js @@ -2,6 +2,7 @@ import axios from 'axios'; import { OpenToast } from './notify'; import logger from '@/lib/logger'; import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; +import { buildAnchors, classifyBooking } from '@/lib/orderFlow'; import { getMilers, getMiler, @@ -699,6 +700,81 @@ export const notifyRider = async ( // page already reads (orderheaderid, deliverycustomer, ridername, etc.) so // deliveries.js itself doesn't need a full rewrite — same compatibility-shim // approach as fetchAllRiders' `{details}` wrapper. +// ==============================|| Facility anchors for flow classification ||============================== // +// +// classifyBooking needs the list of places that count as "a facility, not a +// customer's door": Doormile's own hubs (GET /admin/hubs) plus each client's +// own origins (GET /admin/tenants/:id/locations). Without the second list a +// tenant's kitchen would classify as a customer pickup, which would relabel +// every historical order on the board — so both are required, not optional. +// +// Cached for the life of the session because fetchDeliveries runs once PER +// PAGE of bookings. Re-fetching a tenant's locations on every page of a +// 2,000-order day would add one request per tenant per page for reference data +// that changes about monthly. +// +// Every call is guarded. If an anchor list fails to load, classification +// degrades to "everything is a hub pickup" — precisely what the console did +// before this existed — rather than failing the page. +let hubAnchorSourcePromise = null; +const tenantLocationSourcePromises = new Map(); + +// Always resolves to an array. Guards the throw, the rejected promise AND the +// call that returns nothing at all — this runs on the deliveries path, which is +// the one thing fetchDeliveries cannot recover from, so a reference list that +// misbehaves must degrade classification rather than fail the page. +const safeAnchorList = async (fetcher) => { + try { + const result = await fetcher(); + return Array.isArray(result) ? result : []; + } catch { + return []; + } +}; + +const loadHubAnchorSources = () => { + if (!hubAnchorSourcePromise) { + hubAnchorSourcePromise = safeAnchorList(() => getHubs()); + } + return hubAnchorSourcePromise; +}; + +const loadTenantLocationSources = (tenantId) => { + const key = String(tenantId); + if (!tenantLocationSourcePromises.has(key)) { + tenantLocationSourcePromises.set(key, safeAnchorList(() => getTenantLocations(tenantId))); + } + return tenantLocationSourcePromises.get(key); +}; + +/** Drop the cached facility lists. Call after creating or editing a hub or a + * tenant location, otherwise a newly-added kitchen keeps classifying its own + * orders as customer pickups until the tab is reloaded. */ +export const resetFlowAnchorCache = () => { + hubAnchorSourcePromise = null; + tenantLocationSourcePromises.clear(); +}; + +/** + * The facility anchors for a set of tenants. + * + * Exported because pages that read RAW bookings — Orders, for one — have to + * classify with exactly the same facility list as the deliveries feed does. + * Two pages deriving anchors from two different lists is how the same order + * ends up labelled a customer pickup on one screen and a hub run on the other. + */ +export const fetchFlowAnchors = async (tenantIds = []) => { + const ids = [...new Set((tenantIds || []).filter((id) => id != null))]; + const [hubs, locationLists] = await Promise.all([ + loadHubAnchorSources(), + Promise.all(ids.map((id) => loadTenantLocationSources(id))) + ]); + const tenantLocations = (locationLists || []).flatMap((list) => (Array.isArray(list) ? list : [])); + return buildAnchors({ hubs, tenantLocations }); +}; + +const loadFlowAnchors = (bookings) => fetchFlowAnchors((bookings || []).map((b) => b?.tenantid)); + export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // Every caller builds the key as // [name, appId, userid, status, startdate, enddate, rowsPerPage, …] @@ -819,6 +895,10 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { const dispatched = (bookings || []).filter(isDispatched); + // Which end of each booking is a facility. Loaded from the cache, so this is + // a no-op network-wise on every page after the first. + const flowAnchors = await loadFlowAnchors(dispatched); + const rows = dispatched.map((b) => { const consignmentId = b.consignmentid ?? b.consignment_id; const consignment = consignmentId != null ? consignmentMap.get(String(consignmentId)) : null; @@ -844,6 +924,7 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { const cStatus = consignmentStatusFor(b, consignmentMap); const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time; const effectiveDeliveryStatus = deriveDeliveryStatus(b, consignmentMap); + const flow = classifyBooking(b, flowAnchors); const actualRiderName = miler?.resolvedName || @@ -870,6 +951,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { consignmentid: consignmentId, hubid: b.hubid, sourcehubid: b.sourcehubid, + // The hub that OWNS this order for zone filtering and reporting, which + // is NOT the same question as "where is it collected from". On a customer + // pickup the collection point is a doorstep and the owning hub is the one + // the parcel is inbound to; on a P2P run neither end is a facility and + // this stays null, leaving ZoneContext to fall back to its radius match. + servicinghubid: flow.servicingHubId, tenantid: b.tenantid, tenantname: tenant?.tenantname || '', tenantsuburb: '', @@ -879,7 +966,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { tenantadress: tenant?.primaryemail || '', locationname: tenant?.tenantname || '', locationsuburb: '', - pickupcustomer: tenant?.tenantname || '', + // Which flow this order is, and what its collection point actually is. + // + // `pickupcustomer` used to be the TENANT NAME, which made every order of + // a client claim the same collection point no matter where it was picked + // up. Dispatch groups its kitchen pins on this, so N customer pickups for + // one client collapsed into a single pin sitting on whichever of them + // happened to be first in the array. It is now the real point: the + // facility's name for a hub pickup, the doorstep for a customer pickup. + ordertype: flow.flow, + ordertypelabel: flow.flowLabel, + pickupsource: flow.pickupSource, + droptarget: flow.dropTarget, + // The STABLE grouping key — one per facility, but one per BOOKING for a + // customer pickup, because two customer doors are two different places. + // Group on this, never on the display name: two hubs can share a name. + pickuppointkey: flow.pickupPointKey, + pickupcustomer: flow.pickupPointName || tenant?.tenantname || '', pickupcontactno: '', Pickupaddress: b.pickupaddress || '', pickupaddress: b.pickupaddress || '', diff --git a/src/components/doormile/AddressAutocomplete.jsx b/src/components/doormile/AddressAutocomplete.jsx index b6c5eda..eb5fae4 100644 --- a/src/components/doormile/AddressAutocomplete.jsx +++ b/src/components/doormile/AddressAutocomplete.jsx @@ -1,8 +1,13 @@ import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; -import { Loader2, MapPin, X } from 'lucide-react'; +import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react'; +import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService'; -const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; +export const toPlace = (result) => standardizePlace(result); + +export const geocodeAddress = async (address, options = {}) => { + return await serviceGeocodeAddress(address, options); +}; const useDebouncedCallback = (callback, delay) => { const timeoutRef = useRef(null); @@ -17,68 +22,14 @@ const useDebouncedCallback = (callback, delay) => { ); }; -const buildAddressComponents = (addr = {}) => { - const components = []; - const push = (longName, types) => { - if (longName) components.push({ long_name: longName, short_name: longName, types }); - }; - push(addr.house_number, ['street_number']); - push(addr.road || addr.pedestrian, ['route']); - push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']); - push(addr.city_district || addr.county, ['administrative_area_level_3']); - push(addr.city || addr.town || addr.village, ['locality']); - push(addr.state, ['administrative_area_level_1']); - push(addr.country, ['country']); - push(addr.postcode, ['postal_code']); - return components; -}; - -export const toPlace = (result) => ({ - formatted_address: result.display_name || '', - name: result.display_name?.split(',')[0] || '', - geometry: { - location: { - lat: () => parseFloat(result.lat), - lng: () => parseFloat(result.lon) - } - }, - address_components: buildAddressComponents(result.address) -}); - -const buildParams = (query, bias) => { - const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' }); - if (bias?.lat && bias?.lng) { - const d = 0.5; // ~55km soft bias bounding box - params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); - } - return params; -}; - -export async function geocodeAddress(address, { bias } = {}) { - if (!address || !address.trim()) return null; - try { - const params = buildParams(address.trim(), bias); - params.set('limit', '1'); - const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { - headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' } - }); - if (!res.ok) return null; - const results = await res.json(); - if (!results?.length) return null; - return toPlace(results[0]); - } catch (err) { - console.error('geocodeAddress error:', err); - return null; - } -} - const AddressAutocomplete = ({ id, label, - placeholder = 'Search address…', + placeholder = 'Search address or locality…', value, onChange, onPlaceSelected, + onOpenMapPin, bias, fullWidth = true, disabled = false, @@ -90,14 +41,10 @@ const AddressAutocomplete = ({ const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); - /* Which suggestion the arrow keys are on. -1 is "none highlighted", the state - the list opens in, so Enter never picks a result the operator hasn't moved - to. */ const [activeOption, setActiveOption] = useState(-1); const wrapRef = useRef(null); const activeRef = useRef(0); - // Set by pick(); consumed by the search effect. See the comment there. const skipNextSearchRef = useRef(false); const listId = `${id || 'address'}-suggestions`; const optionId = (index) => `${listId}-option-${index}`; @@ -112,11 +59,7 @@ const AddressAutocomplete = ({ const fetchPredictions = useDebouncedCallback((query) => { const seq = ++activeRef.current; - const params = buildParams(query, bias); - fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { - headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' } - }) - .then((res) => (res.ok ? res.json() : [])) + getAddressSuggestions(query, { bias, limit: 6 }) .then((results) => { if (seq !== activeRef.current) return; setLoading(false); @@ -128,19 +71,14 @@ const AddressAutocomplete = ({ setLoading(false); setOptions([]); }); - }, 450); + }, 220); useEffect(() => { - /* Picking a suggestion writes the resolved address back into inputValue, - which used to re-trigger this search: the list reopened over the form the - instant it was dismissed, and every selection cost a second Nominatim - request against a service that asks for one per second. The pick is not a - new query, so skip exactly one run. */ if (skipNextSearchRef.current) { skipNextSearchRef.current = false; return; } - if (!inputValue || inputValue.length < 3) { + if (!inputValue || inputValue.length < 2) { activeRef.current++; setOptions([]); setLoading(false); @@ -151,8 +89,6 @@ const AddressAutocomplete = ({ fetchPredictions(inputValue); }, [inputValue, biasKey]); - /* A fresh result set invalidates the highlight — otherwise index 2 of the old - list silently becomes index 2 of the new one. */ useEffect(() => { setActiveOption(-1); }, [options]); @@ -168,23 +104,16 @@ const AddressAutocomplete = ({ }, []); const pick = (option) => { - const formatted = option.display_name || ''; + const formatted = option.formatted_address || option.name || ''; skipNextSearchRef.current = true; - activeRef.current++; // drop any in-flight response that would reopen the list + activeRef.current++; setInputValue(formatted); onChange?.(formatted); - onPlaceSelected?.(toPlace(option)); + onPlaceSelected?.(option); setIsOpen(false); setActiveOption(-1); }; - /** - * Arrow keys, Enter and Escape over the suggestion list. - * - * Not a nicety: an order cannot be created from typed text — the submit gate - * requires the coordinates that only picking a suggestion supplies — so - * without this the whole create-order flow needed a mouse. - */ const onKeyDown = (e) => { const listOpen = isOpen && options.length > 0; if (e.key === 'Escape') { @@ -200,8 +129,6 @@ const AddressAutocomplete = ({ e.preventDefault(); setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1)); } else if (e.key === 'Enter' && activeOption >= 0) { - // Only swallow Enter when a suggestion is actually highlighted, so Enter - // on a bare query still submits the surrounding form. e.preventDefault(); pick(options[activeOption]); } @@ -209,11 +136,25 @@ const AddressAutocomplete = ({ return (
- {label && ( - - )} +
+ {label && ( + + )} + {onOpenMapPin && ( + + )} +
+
- {loading ? ( - - - - ) : inputValue && !disabled ? ( - - ) : null} + {/* Action icons on the right */} +
+ {loading ? ( + + + + ) : inputValue && !disabled ? ( + + ) : null} + + {onOpenMapPin && !disabled && ( + + )} +
{isOpen && options.length > 0 && (
    - {options.map((option, index) => ( - /* The option role sits on the
  • , which is what aria-activedescendant - points at; the button inside stays for the mouse. Without this the - popup announced as an empty listbox. */ -
  • - -
  • - ))} + + + ); + })}
)}
@@ -307,6 +276,7 @@ AddressAutocomplete.propTypes = { value: PropTypes.string, onChange: PropTypes.func, onPlaceSelected: PropTypes.func, + onOpenMapPin: PropTypes.func, bias: PropTypes.object, fullWidth: PropTypes.bool, disabled: PropTypes.bool, diff --git a/src/components/doormile/MapPinPickerModal.jsx b/src/components/doormile/MapPinPickerModal.jsx new file mode 100644 index 0000000..55d61fa --- /dev/null +++ b/src/components/doormile/MapPinPickerModal.jsx @@ -0,0 +1,350 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet'; +import L from 'leaflet'; +import { Check, Crosshair, Loader2, MapPin, Search, X } from 'lucide-react'; +import { getAddressSuggestions, reverseGeocode } from '@/lib/geocodingService'; + +// Fix default leaflet marker icon +const customMarkerIcon = new L.DivIcon({ + className: 'custom-pin-marker', + html: ` +
+
+ + + + +
+
+
+
+ `, + iconSize: [32, 42], + iconAnchor: [16, 42] +}); + +// Map click listener component +function LocationMarker({ position, onLocationChange }) { + useMapEvents({ + click(e) { + onLocationChange(e.latlng.lat, e.latlng.lng); + } + }); + + const markerRef = useRef(null); + + const eventHandlers = useMemo( + () => ({ + dragend() { + const marker = markerRef.current; + if (marker != null) { + const latlng = marker.getLatLng(); + onLocationChange(latlng.lat, latlng.lng); + } + } + }), + [onLocationChange] + ); + + return position ? ( + + ) : null; +} + +LocationMarker.propTypes = { + position: PropTypes.shape({ + lat: PropTypes.number, + lng: PropTypes.number + }), + onLocationChange: PropTypes.func.isRequired +}; + +// Map center adjuster +function ChangeMapView({ center, zoom }) { + const map = useMap(); + useEffect(() => { + if (center && Number.isFinite(center[0]) && Number.isFinite(center[1])) { + map.setView(center, zoom || map.getZoom()); + } + }, [center, zoom, map]); + return null; +} + +ChangeMapView.propTypes = { + center: PropTypes.arrayOf(PropTypes.number), + zoom: PropTypes.number +}; + +export default function MapPinPickerModal({ + isOpen, + onClose, + onConfirm, + initialLat, + initialLng, + initialAddress = '', + title = 'Pin Exact Location on Map' +}) { + // Default coordinates (Bengaluru / Central fallback if none provided) + const defaultLat = Number(initialLat) || 12.9716; + const defaultLng = Number(initialLng) || 77.5946; + + const [coords, setCoords] = useState({ lat: defaultLat, lng: defaultLng }); + const [mapCenter, setMapCenter] = useState([defaultLat, defaultLng]); + const [zoomLevel, setZoomLevel] = useState(initialLat && initialLng ? 16 : 13); + + const [resolvedPlace, setResolvedPlace] = useState(null); + const [isResolving, setIsResolving] = useState(false); + + // Search in modal + const [searchQuery, setSearchQuery] = useState(initialAddress || ''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + if (isOpen) { + const lat = Number(initialLat) || 12.9716; + const lng = Number(initialLng) || 77.5946; + setCoords({ lat, lng }); + setMapCenter([lat, lng]); + setZoomLevel(initialLat && initialLng ? 16 : 13); + setSearchQuery(initialAddress || ''); + setSearchResults([]); + handleCoordUpdate(lat, lng); + } + }, [isOpen, initialLat, initialLng, initialAddress]); + + const handleCoordUpdate = async (lat, lng) => { + setCoords({ lat, lng }); + setIsResolving(true); + try { + const place = await reverseGeocode(lat, lng); + setResolvedPlace(place); + } catch (e) { + console.warn('Reverse geocode error:', e); + } finally { + setIsResolving(false); + } + }; + + const handleSearch = async (e) => { + e?.preventDefault(); + if (!searchQuery.trim()) return; + setIsSearching(true); + try { + const results = await getAddressSuggestions(searchQuery, { limit: 5 }); + setSearchResults(results); + } catch (err) { + console.warn('Modal search failed:', err); + } finally { + setIsSearching(false); + } + }; + + const handlePickSearchResult = (result) => { + if (result.latitude && result.longitude) { + setMapCenter([result.latitude, result.longitude]); + setZoomLevel(16); + handleCoordUpdate(result.latitude, result.longitude); + setSearchResults([]); + setSearchQuery(result.formatted_address); + } + }; + + const handleCurrentLocation = () => { + if ('geolocation' in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const lat = pos.coords.latitude; + const lng = pos.coords.longitude; + setMapCenter([lat, lng]); + setZoomLevel(17); + handleCoordUpdate(lat, lng); + }, + (err) => { + console.warn('Geolocation error:', err); + } + ); + } + }; + + const handleConfirm = () => { + if (!coords.lat || !coords.lng) return; + const finalPlace = resolvedPlace || { + formatted_address: initialAddress || `Coordinates (${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)})`, + name: 'Selected Pin', + latitude: coords.lat, + longitude: coords.lng, + suburb: '', + city: '', + postcode: '' + }; + + onConfirm?.(finalPlace); + onClose?.(); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

{title}

+

Click anywhere or drag the pin to set exact coordinates

+
+
+ +
+ + {/* Search & Location Bar */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full h-9 pl-9 pr-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black shadow-2xs" + /> + {isSearching && ( + + + + )} +
+ + + + +
+ + {/* Search suggestions dropdown in modal */} + {searchResults.length > 0 && ( +
    + {searchResults.map((item, idx) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Leaflet Map Area */} +
+ + + + + + + {/* Pin Instruction Overlay */} +
+ + Click map or drag pin to position +
+
+ + {/* Footer: Resolved Address & Confirmation */} +
+
+
Selected Location
+ {isResolving ? ( +
+ + Identifying street address… +
+ ) : ( +
+ {resolvedPlace?.formatted_address || `${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}`} +
+ )} +
+ Lat: {coords.lat.toFixed(6)}, Lng: {coords.lng.toFixed(6)} +
+
+ +
+ + +
+
+
+
+ ); +} + +MapPinPickerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + initialLat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + initialLng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + initialAddress: PropTypes.string, + title: PropTypes.string +}; diff --git a/src/components/ds/DataTable.jsx b/src/components/ds/DataTable.jsx index 2a0b747..e291924 100644 --- a/src/components/ds/DataTable.jsx +++ b/src/components/ds/DataTable.jsx @@ -42,6 +42,7 @@ export function DataTable({ /* Selection — pass `selectedIds` to enable */ selectedIds, onSelectionChange, + selectAllPages = true, /* Pagination — pass `pageSize` to enable */ pageSize: initialPageSize, @@ -138,15 +139,22 @@ export function DataTable({ /* ── Selection ───────────────────────────────────────────────────────── */ const selectable = selectedIds !== undefined; const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]); + + const allRowIds = React.useMemo( + () => sortedRows.map((row, i) => getRowId(row, i)), + [sortedRows, getRowId] + ); const pageIds = visibleRows.map((row, i) => getRowId(row, i)); - const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id)); - const someOnPageSelected = pageIds.some((id) => selected.has(id)); + + const targetIds = selectAllPages && !serverPaginated ? allRowIds : pageIds; + const allTargetSelected = targetIds.length > 0 && targetIds.every((id) => selected.has(id)); + const someTargetSelected = targetIds.some((id) => selected.has(id)); const toggleAll = () => { - if (allOnPageSelected) { - onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id))); + if (allTargetSelected) { + onSelectionChange?.(selectedIds.filter((id) => !targetIds.includes(id))); } else { - onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]); + onSelectionChange?.([...new Set([...selectedIds, ...targetIds])]); } }; @@ -202,9 +210,13 @@ export function DataTable({ {selectable && ( )} diff --git a/src/components/nearle_components/AddressAutocomplete.jsx b/src/components/nearle_components/AddressAutocomplete.jsx index 663e57c..4112344 100644 --- a/src/components/nearle_components/AddressAutocomplete.jsx +++ b/src/components/nearle_components/AddressAutocomplete.jsx @@ -8,76 +8,15 @@ import { Text } from '@astryxdesign/core/Text'; import { DT } from 'themes/dt/tokens'; -// Free OSM Nominatim geocoder — replaces Google Places Autocomplete / -// react-geocode across the console. No API key required. Nominatim's usage -// policy caps the public endpoint at ~1 req/sec, so every caller here is -// debounced (500ms) and cancels stale in-flight predictions. -const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; +import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService'; -// Builds a Google-Places-shaped `address_components` array (each entry -// `{ long_name, short_name, types: [] }`) from Nominatim's flat `address` -// object, so existing call sites' `place.address_components.forEach(...)` -// type-switch parsing keeps working unchanged. -const buildAddressComponents = (addr = {}) => { - const components = []; - const push = (longName, types) => { - if (longName) components.push({ long_name: longName, short_name: longName, types }); - }; - push(addr.house_number, ['street_number']); - push(addr.road || addr.pedestrian, ['route']); - push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']); - push(addr.city_district || addr.county, ['administrative_area_level_3']); - push(addr.city || addr.town || addr.village, ['locality']); - push(addr.state, ['administrative_area_level_1']); - push(addr.country, ['country']); - push(addr.postcode, ['postal_code']); - return components; -}; +export const toPlace = (result) => standardizePlace(result); -// Shapes a Nominatim result close enough to a Google Places `place` object -// (formatted_address, geometry.location.lat()/lng(), address_components) -// that call sites need only mechanical edits, not rewrites. -export const toPlace = (result) => ({ - formatted_address: result.display_name, - name: result.display_name?.split(',')[0] || '', - geometry: { - location: { - lat: () => parseFloat(result.lat), - lng: () => parseFloat(result.lon) - } - }, - address_components: buildAddressComponents(result.address) -}); - -const buildParams = (query, bias) => { - const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' }); - if (bias?.lat && bias?.lng) { - const d = 0.5; // ~55km box around the operator's zone — soft bias, not a hard filter - params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); - } - return params; -}; - -// Standalone forward-geocode helper — replaces `Geocode.fromAddress(address)` -// for call sites that resolve a plain text address without a predictions -// dropdown (e.g. re-geocoding on submit). Returns a `toPlace`-shaped object, -// or null if nothing matched. -export async function geocodeAddress(address, { bias } = {}) { - if (!address) return null; - try { - const params = buildParams(address, bias); - params.set('limit', '1'); - const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }); - if (!res.ok) return null; - const results = await res.json(); - if (!results?.length) return null; - return toPlace(results[0]); - } catch (err) { - console.error('geocodeAddress error:', err); - return null; - } +export async function geocodeAddress(address, options = {}) { + return await serviceGeocodeAddress(address, options); } + // Address search-as-you-type — replaces Google Places Autocomplete across the // console. // @@ -121,9 +60,7 @@ const AddressAutocomplete = ({ const fetchPredictions = useDebouncedCallback((query) => { const seq = ++activeRef.current; - const params = buildParams(query, bias); - fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }) - .then((res) => (res.ok ? res.json() : [])) + getAddressSuggestions(query, { bias, limit: 6 }) .then((results) => { if (seq !== activeRef.current) return; // a newer keystroke won setLoading(false); @@ -135,10 +72,10 @@ const AddressAutocomplete = ({ setLoading(false); setOptions([]); }); - }, 500); + }, 250); useEffect(() => { - if (!inputValue) { + if (!inputValue || inputValue.length < 2) { activeRef.current++; // invalidate any in-flight request setOptions([]); setLoading(false); @@ -178,9 +115,10 @@ const AddressAutocomplete = ({ ); const pick = (option) => { - setInputValue(option.display_name || ''); - onChange?.(option.display_name || ''); - onPlaceSelected?.(toPlace(option)); + const formatted = option.formatted_address || option.name || ''; + setInputValue(formatted); + onChange?.(formatted); + onPlaceSelected?.(option); setIsOpen(false); }; @@ -212,8 +150,8 @@ const AddressAutocomplete = ({ {isOpen && options.length > 0 && (
    - {options.map((option) => ( -
  • + {options.map((option, idx) => ( +
  • @@ -243,6 +181,7 @@ const AddressAutocomplete = ({ )} ); + }; AddressAutocomplete.propTypes = { diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx index 924904b..228cdba 100644 --- a/src/layouts/AdminLayout.jsx +++ b/src/layouts/AdminLayout.jsx @@ -47,6 +47,7 @@ const LOGO_FILTER = { /** Destinations that sit directly on the header bar. */ const NAV = [ { label: 'Dispatch', path: '/doormile/dispatch' }, + { label: 'Bookings', path: '/doormile/bookings' }, { label: 'Orders', path: '/doormile/orders' }, { label: 'Deliveries', path: '/doormile/deliveries' }, { label: 'Milers', path: '/doormile/milers' }, diff --git a/src/lib/ZoneContext.jsx b/src/lib/ZoneContext.jsx index 01ee7a5..690d237 100644 --- a/src/lib/ZoneContext.jsx +++ b/src/lib/ZoneContext.jsx @@ -146,7 +146,16 @@ export function ZoneProvider({ children }) { } // 1. Direct hub/location id match + // + // `servicinghubid` is queries.js's answer to "which hub OWNS this order", + // which is a different question from "where is it collected from" and the + // only one that stays answerable once the pickup is a customer's doorstep. + // It is consulted first, and only for staff sessions: a tenant's zones are + // its own locations, so a hub id compared against a tenantlocationid would + // match on a bare numeric collision and file the order under a zone that + // has nothing to do with it. const itemHubId = + (!isTenantUser ? item.servicinghubid : null) ?? item.hubid ?? item.sourcehubid ?? item.applocationid ?? diff --git a/src/lib/assistant/scan.js b/src/lib/assistant/scan.js index 1e7dff8..c58039f 100644 --- a/src/lib/assistant/scan.js +++ b/src/lib/assistant/scan.js @@ -4,11 +4,19 @@ import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; /** * Reading bookings for the assistant. * - * `GET /admin/bookings` has no date, status or tenant filter and caps `pagesize` - * at 1000 server-side, so every question is answered by draining pages and - * filtering here. A single `getBookings(1, 1000)` silently under-reports the - * moment an account passes 1000 lifetime bookings, which is why nothing in the - * assistant calls it. + * `GET /admin/bookings` caps `pagesize` at 100 server-side — NOT 1000, which is + * what this comment claimed for a long time and what `express-console-api.md` + * documented. A page is 100 rows, so MAX_PAGES x BULK_PAGESIZE is not the + * reachable total: 12 pages is 1200 rows, not 12,000. + * + * It IS tenant-scoped, and it does accept `?status=` — but as an exact, + * case-sensitive, single-value match against the stored capitalised enum + * (`Pending_Pickup`). The status GROUPS this console renders as tabs each cover + * several values in lowercase, so the server filter cannot express a tab and + * the grouping still happens here. There is no date filter. + * + * The list is ordered `bookingid DESC`, so page 1 is the newest page and a + * drain that stops early keeps the most recent bookings rather than the oldest. * * Every scan returns `{ rows, truncated, scanned, pagesFetched, total }` rather * than a bare array, and **`truncated` is not optional to handle**. A count diff --git a/src/lib/bookingDrops.js b/src/lib/bookingDrops.js new file mode 100644 index 0000000..64fc16e --- /dev/null +++ b/src/lib/bookingDrops.js @@ -0,0 +1,54 @@ +// ============================================================================ +// One pickup, many drops. +// +// A customer-app booking is ONE pickup carrying N destinations — never N +// bookings. `GET /admin/bookings` reports how many each row has +// (`destinationcount`, `totalpackagecount`) without shipping the destination +// array, which is fetched only when a row is opened. +// +// Every helper here has to survive three shapes of row, because all three +// reach the Orders table at once: +// +// 1. The console running AHEAD of the backend that adds these fields, where +// `destinationcount` is undefined. Nothing may change for that row. +// 2. An ordinary single-drop booking, which is most of them. +// 3. A console-created booking, which has no bookingdestinations rows at all +// and reports 0 — distinct from 1, and rendered the same as case 1. +// ============================================================================ + +export const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`; + +/** + * How many drops one pickup carries, or 0 when the row cannot say. + * + * `Number(undefined) || 0` collapses the absent-field case to 0, which is what + * keeps a console deployed before the backend rendering exactly as it does + * today. + */ +export const dropCountOf = (row) => Number(row?.destinationcount) || 0; + +/** + * Whether this pickup created more than one shipment. + * + * The single gate for every multi-drop behaviour on the Orders page: the + * summary line, and — more importantly — the refusal to render the booking's + * own `consignmentstatus` as the row's status. Deliberately `> 1`, never + * `>= 1`: "1 destination · 1 package" on every B2C row is noise on the common + * case, and a single-drop booking's consignment status IS the booking's. + */ +export const isMultiDrop = (row) => dropCountOf(row) > 1; + +/** + * The collapsed row's summary — "3 destinations · 4 packages". + * + * Only meaningful when `isMultiDrop(row)`; callers gate on that. + */ +export const dropSummary = (row) => { + const drops = dropCountOf(row); + /* Packages fall back to the destination count rather than to zero. Every + destination carries at least one package, so "3 destinations · 0 packages" + describes a booking the data cannot actually produce — and would be the + reading if the count arrived without the sum. */ + const packages = Number(row?.totalpackagecount) || drops; + return `${plural(drops, 'destination')} · ${plural(packages, 'package')}`; +}; diff --git a/src/lib/bulkOrderPayload.js b/src/lib/bulkOrderPayload.js new file mode 100644 index 0000000..b7ec215 --- /dev/null +++ b/src/lib/bulkOrderPayload.js @@ -0,0 +1,120 @@ +// ==============================|| Bulk sheet row → booking payload ||============================== // +// +// The transform that turns one processed spreadsheet row into one +// POST /admin/expressbooking body. It lived inline inside MultipleOrders.jsx, +// where nothing could reach it: the only way to find out what a tenant's sheet +// actually produced was to upload it and read the orders afterwards. +// +// Pulled out so the real transform can be driven by a test against a real +// tenant file. The page imports it; nothing is reimplemented in either place, +// so a test that passes is a statement about what the page does, not about a +// copy of it that happens to agree today. + +import { PICKUP_SOURCE, buildFlowFields, flowForDraft } from '@/lib/orderFlow'; + +export const BULK_SERVICE_OPTION = 'Normal'; +export const DEFAULT_ITEM_CATEGORY = 'General'; + +/** + * The collection point for one row. + * + * `__pickup` travels on the ROW, not on the page. A single file can carry + * several origins — a real one carries three home kitchens across thirteen + * drops — and folding those onto one shared pickup silently re-addresses every + * row that did not come from it. + */ +export const rowPickupOf = (row, sharedPickup) => row?.__pickup || sharedPickup || null; + +/** + * Parcels for one row. + * + * A parcel entry has NO quantity field: N items means N entries, which is how + * the rest of the app reads it back (`Quantity: b.parcels?.length`, see + * queries.js). This is why quantity has to expand here rather than travel as a + * number — a single entry for a five-item order reports as one parcel + * everywhere downstream, and the rider is told to collect one bag. + */ +export const buildParcels = (row) => { + const count = Math.max(1, Math.floor(Number(row?.quantity)) || 1); + const description = String(row?.description || '').trim() || 'Order'; + const declaredvalue = Number(row?.totalcharge) || 0; + return Array.from({ length: count }, () => ({ + itemcategory: DEFAULT_ITEM_CATEGORY, + itemdescription: description, + declaredvalue + })); +}; + +/** + * One processed row → one booking payload. + * + * `row` is a drop as MultipleOrders holds it after processing: sheet fields + * mapped to internal names, coordinates resolved, `distance`/`totalcharge` + * computed, and `__pickup` attached when the run collects from customers. + */ +export const buildBulkBookingPayload = ({ + row, + sharedPickup, + tenantId, + tenantLocationId, + deliverytime, + notes = '', + anchors = [] +}) => { + const origin = rowPickupOf(row, sharedPickup) || {}; + + const flow = flowForDraft({ + // A row that resolved its own collection point is a customer pickup. One + // that fell back to the shared location is not — it really is being + // collected at the hub, and labelling it otherwise would put a doorstep + // pin on a facility. This is only the HINT: the coordinates below outrank + // it, so a sender address that turns out to be the tenant's own registered + // kitchen is booked as the hub run it is. + pickupSource: row?.__pickup ? PICKUP_SOURCE.CUSTOMER : PICKUP_SOURCE.HUB, + pickupLat: origin.latitude, + pickupLng: origin.longitude, + dropLat: row?.latitude, + dropLng: row?.longitude, + anchors + }); + + return { + tenantid: Number(tenantId), + pickupaddress: origin.address || '', + pickuppincode: String(origin.pincode || origin.postcode || ''), + pickupcity: origin.city || '', + pickuplatitude: Number(origin.latitude) || 0, + pickuplongitude: Number(origin.longitude) || 0, + customer_phone: row?.contactno != null ? String(row.contactno) : '', + customer_name: row?.firstname || '', + deliveryaddress: row?.address || '', + deliverypincode: row?.postcode != null ? String(row.postcode) : '', + deliverycity: row?.city || '', + deliverylatitude: Number(row?.latitude) || 0, + deliverylongitude: Number(row?.longitude) || 0, + deliverytime, + service_option: BULK_SERVICE_OPTION, + finalprice: Number(row?.totalcharge) || 0, + notes: notes || '', + ...buildFlowFields({ + flow: flow.flow, + // The facility we collect from, then the one we deliver to, then the + // selected location's own hub. A P2P row matches neither end, and falls + // through to the third — which is right: the run still belongs to the + // hub servicing it, even though no facility is a stop on the journey. + servicingHubId: + flow.originAnchor?.hubid ?? flow.destAnchor?.hubid ?? sharedPickup?.hubid ?? null, + // Same reasoning as the single-order form: the row records what kind of + // place it is collected from, rather than leaving every reader to + // re-derive it from two coordinates. + pickupAnchor: flow.originAnchor, + tenantLocationId + }), + parcels: buildParcels(row) + }; +}; + +/** The whole run. Kept as a thin map so the page and the test agree on the + * ordering and count of what goes on the wire, not just on each row's shape. */ +export const buildBulkBookingPayloads = ({ rows = [], ...shared }) => + rows.map((row) => buildBulkBookingPayload({ row, ...shared })); diff --git a/src/lib/customerAppBookings.js b/src/lib/customerAppBookings.js new file mode 100644 index 0000000..5e00433 --- /dev/null +++ b/src/lib/customerAppBookings.js @@ -0,0 +1,110 @@ +// ==============================|| Customer-app bookings ||============================== // +// +// Which bookings came from the B2C customer app, as opposed to being created in +// this console. +// +// `bookingsource` is the only field that separates them, and its stored value is +// `"Customer_App"` vs `"CRM_Console"`. Note the second one: the outward API and +// routes were renamed from "CRM" to "express", but the stored column value was +// deliberately left alone — it is existing data, not a label. Matching on +// "express" here would find nothing. +// +// The predicate lives in lib/ rather than inline in the page so the rule is +// stated once and can be tested on its own. + +export const BOOKING_SOURCE = Object.freeze({ + CUSTOMER_APP: 'Customer_App', + CONSOLE: 'CRM_Console', +}); + +/** + * True when a booking was made by a customer in the app. + * + * Matched case-insensitively and trimmed, because this value is compared against + * a raw database column rather than a validated enum, and a stray space would + * silently empty the page. + * + * A booking with NO bookingsource is not counted. The column defaults to + * `Customer_App` at the database level, so a blank is almost certainly a console + * booking written before the column existed — and quietly filing unknown rows + * under "customer app" would overstate B2C volume, which is a number people + * make decisions on. + */ +export const isCustomerAppBooking = (booking) => + String(booking?.bookingsource || '').trim().toLowerCase() === + BOOKING_SOURCE.CUSTOMER_APP.toLowerCase(); + +/** Every customer-app booking in a list, newest first. */ +export const customerAppBookings = (bookings) => + (bookings || []) + .filter(isCustomerAppBooking) + .sort((a, b) => (Number(b?.bookingid) || 0) - (Number(a?.bookingid) || 0)); + +/** + * The price a booking was quoted. + * + * This is the same row `GET /customer/bookings/{id}/price` returns — that + * endpoint reads the booking's newest `BookingServiceOption`, which is already + * preloaded onto every admin booking row. So the console needs no separate call + * for it, and there is no state where the list and the quote disagree. + */ +export const serviceOptionOf = (booking) => { + const options = booking?.serviceoptions || []; + if (options.length === 0) return null; + // Newest wins, matching the backend's `ORDER BY createdat DESC LIMIT 1`. + return [...options].sort( + (a, b) => new Date(b?.createdat || 0) - new Date(a?.createdat || 0) + )[0]; +}; + +export const quotedPrice = (booking) => Number(serviceOptionOf(booking)?.estimatedprice) || 0; +export const serviceType = (booking) => serviceOptionOf(booking)?.servicetype || ''; + +/** + * The tabs on the page: Created, Rider Assigned, Delivered, Cancelled, and All. + */ +export const BOOKING_TABS = [ + { value: 'all', label: 'All' }, + { value: 'created', label: 'Created' }, + { value: 'assigned', label: 'Rider Assigned' }, + { value: 'delivered', label: 'Delivered' }, + { value: 'cancelled', label: 'Cancelled' }, +]; + +export const isBookingInTab = (booking, tabValue) => { + const status = String(booking?.status || '').trim().toLowerCase(); + const consignmentStatus = String(booking?.consignmentstatus || booking?.consignment_status || '').trim().toLowerCase(); + const hasRider = Boolean(booking?.assignedmileruserid || booking?.mileruserid); + + if (tabValue === 'all') return true; + + if (tabValue === 'cancelled') { + return status === 'cancelled' || consignmentStatus === 'cancelled'; + } + + if (tabValue === 'delivered') { + return status === 'delivered' || consignmentStatus === 'delivered'; + } + + if (tabValue === 'created') { + if (status === 'cancelled' || consignmentStatus === 'cancelled' || status === 'delivered' || consignmentStatus === 'delivered') { + return false; + } + // Unassigned created / pending pickup orders + return (status === 'created' || status === 'pending_pickup') && !hasRider; + } + + if (tabValue === 'assigned') { + if (status === 'cancelled' || consignmentStatus === 'cancelled' || status === 'delivered' || consignmentStatus === 'delivered') { + return false; + } + return hasRider || status === 'miler_assigned' || status === 'pickup_scheduled' || status === 'picked_up' || status === 'converted_to_consignment' || status === 'out_for_delivery' || status === 'inwarded_at_hub'; + } + + return true; +}; + +/** Rows for one tab. */ +export const bookingsForTab = (bookings, tabValue) => { + return (bookings || []).filter((b) => isBookingInTab(b, tabValue)); +}; diff --git a/src/lib/doormileHooks.js b/src/lib/doormileHooks.js index 4a6f8d7..d6beba6 100644 --- a/src/lib/doormileHooks.js +++ b/src/lib/doormileHooks.js @@ -259,6 +259,27 @@ export const useHubs = (options) => export const useHub = (id, options) => useQuery({ queryKey: KEYS.hub(id), queryFn: () => api.getHub(id), enabled: !!id, ...options }); +/** + * The facility list used to decide which end of a booking is a hub. + * + * Lives here rather than in each page so every screen classifies against the + * SAME facilities. A page that built its own list from hubs alone would call + * every client's kitchen a customer pickup, and the same order would then carry + * different labels on Orders and on Dispatch. + * + * `tenantIds` is sorted into the cache key so the same set of tenants in a + * different order is one cache entry, not two. + */ +export const useFlowAnchors = (tenantIds = [], options) => { + const ids = [...new Set((tenantIds || []).filter((id) => id != null).map(String))].sort(); + return useQuery({ + queryKey: ['doormile', 'flowAnchors', ids.join(',')], + queryFn: () => api.fetchFlowAnchors(ids), + staleTime: 5 * 60_000, + ...options, + }); +}; + export const useCreateHub = () => useDoormileMutation({ mutationFn: api.createHub, invalidates: [KEYS.hubs], successMessage: 'Hub created' }); diff --git a/src/lib/geocodingService.js b/src/lib/geocodingService.js new file mode 100644 index 0000000..f5c1fc1 --- /dev/null +++ b/src/lib/geocodingService.js @@ -0,0 +1,352 @@ +/** + * Enterprise Geocoding & Address Autocomplete Service + * + * Supports multi-provider hierarchy: + * 1. Ola Maps Places Autocomplete (if VITE_OLA_MAPS_API_KEY is configured) + * 2. Google Places API (if VITE_GOOGLE_MAPS_API_KEY is configured) + * 3. Photon (Komoot OSM - high-speed, typo-tolerant, sub-100ms autocomplete) + * 4. Nominatim (OpenStreetMap standard geocoder with country lock) + */ + +const getEnvVar = (key) => { + try { + if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env[key]) { + return import.meta.env[key]; + } + } catch {} + try { + if (typeof process !== 'undefined' && process.env && process.env[key]) { + return process.env[key]; + } + } catch {} + return ''; +}; + +const OLA_MAPS_KEY = getEnvVar('VITE_OLA_MAPS_API_KEY'); + +const PHOTON_API_URL = 'https://photon.komoot.io/api'; +const PHOTON_REVERSE_URL = 'https://photon.komoot.io/reverse'; +const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; +const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse'; + +/** + * Standardize place result into unified format for Doormile order forms + */ +export const standardizePlace = ({ + formatted_address = '', + name = '', + suburb = '', + city = '', + state = '', + postcode = '', + latitude = null, + longitude = null, + provider = 'unknown', + raw = null +}) => { + const latNum = latitude != null ? Number(latitude) : null; + const lngNum = longitude != null ? Number(longitude) : null; + + return { + formatted_address: formatted_address || name, + name: name || formatted_address.split(',')[0] || '', + suburb: suburb || '', + city: city || '', + state: state || '', + postcode: postcode || '', + latitude: Number.isFinite(latNum) ? latNum : null, + longitude: Number.isFinite(lngNum) ? lngNum : null, + provider, + raw, + // Google Places / Legacy compatibility interface + geometry: { + location: { + lat: () => (Number.isFinite(latNum) ? latNum : 0), + lng: () => (Number.isFinite(lngNum) ? lngNum : 0) + } + }, + address_components: [ + ...(suburb ? [{ long_name: suburb, short_name: suburb, types: ['sublocality_level_1', 'sublocality'] }] : []), + ...(city ? [{ long_name: city, short_name: city, types: ['locality'] }] : []), + ...(state ? [{ long_name: state, short_name: state, types: ['administrative_area_level_1'] }] : []), + ...(postcode ? [{ long_name: postcode, short_name: postcode, types: ['postal_code'] }] : []) + ] + }; +}; + +/** + * 1. Photon (Komoot OSM) Search - Fast, typo-tolerant, ideal for real-time keystroke suggestions + */ +async function searchPhoton(query, { bias, limit = 6 } = {}) { + try { + const params = new URLSearchParams({ + q: query, + limit: String(limit), + lang: 'en' + }); + + if (bias?.lat && bias?.lng) { + params.set('lat', String(bias.lat)); + params.set('lon', String(bias.lng)); + } + + const res = await fetch(`${PHOTON_API_URL}?${params.toString()}`, { + headers: { Accept: 'application/json' } + }); + + if (!res.ok) return []; + const data = await res.json(); + if (!data?.features || !Array.isArray(data.features)) return []; + + return data.features.map((item) => { + const p = item.properties || {}; + const [lon, lat] = item.geometry?.coordinates || [null, null]; + + const parts = [ + p.name, + p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', + p.district || p.suburb || p.neighbourhood || p.locality, + p.city, + p.state, + p.postcode, + p.country + ].filter(Boolean); + + // Remove consecutive duplicates in address string + const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx); + const formatted = uniqueParts.join(', '); + + return standardizePlace({ + formatted_address: formatted || p.name || '', + name: p.name || formatted.split(',')[0], + suburb: p.district || p.suburb || p.neighbourhood || p.locality || '', + city: p.city || p.county || '', + state: p.state || '', + postcode: p.postcode || '', + latitude: lat, + longitude: lon, + provider: 'photon', + raw: item + }); + }); + } catch (err) { + console.warn('[Geocoding] Photon search failed:', err); + return []; + } +} + +/** + * 2. Nominatim Search - Fallback with countrycodes=in & addressdetails + */ +async function searchNominatim(query, { bias, country = 'in', limit = 5 } = {}) { + try { + const params = new URLSearchParams({ + q: query, + format: 'json', + addressdetails: '1', + limit: String(limit) + }); + + if (country) { + params.set('countrycodes', country.toLowerCase()); + } + + if (bias?.lat && bias?.lng) { + const d = 0.5; // ~55km bounding box + params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); + } + + const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { + headers: { + Accept: 'application/json', + 'Accept-Language': 'en-GB,en;q=0.9', + 'User-Agent': 'DoormileConsole/1.0' + } + }); + + if (!res.ok) return []; + const results = await res.json(); + if (!Array.isArray(results)) return []; + + return results.map((r) => { + const addr = r.address || {}; + const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || ''; + const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || ''; + const state = addr.state || ''; + const postcode = addr.postcode || ''; + + return standardizePlace({ + formatted_address: r.display_name || '', + name: r.display_name?.split(',')[0] || '', + suburb, + city, + state, + postcode, + latitude: r.lat, + longitude: r.lon, + provider: 'nominatim', + raw: r + }); + }); + } catch (err) { + console.warn('[Geocoding] Nominatim search failed:', err); + return []; + } +} + +/** + * 3. Ola Maps Places Autocomplete (if key configured) + */ +async function searchOlaMaps(query, { bias, limit = 5 } = {}) { + if (!OLA_MAPS_KEY) return []; + try { + const params = new URLSearchParams({ + input: query, + api_key: OLA_MAPS_KEY + }); + if (bias?.lat && bias?.lng) { + params.set('location', `${bias.lat},${bias.lng}`); + params.set('radius', '50000'); + } + + const res = await fetch(`https://api.olamaps.io/places/v1/autocomplete?${params.toString()}`); + if (!res.ok) return []; + const data = await res.json(); + if (!data?.predictions || !Array.isArray(data.predictions)) return []; + + return data.predictions.slice(0, limit).map((p) => { + const lat = p.geometry?.location?.lat; + const lng = p.geometry?.location?.lng; + return standardizePlace({ + formatted_address: p.description || '', + name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '', + suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '', + city: '', + state: '', + postcode: '', + latitude: lat, + longitude: lng, + provider: 'olamaps', + raw: p + }); + }); + } catch (err) { + console.warn('[Geocoding] Ola Maps autocomplete failed:', err); + return []; + } +} + +/** + * Primary Address Autocomplete Dispatcher + * Queries fastest/best provider first, then falls back seamlessly. + */ +export async function getAddressSuggestions(query, options = {}) { + const cleanQuery = (query || '').trim(); + if (!cleanQuery || cleanQuery.length < 2) return []; + + // 1. Try Ola Maps if API key is present + if (OLA_MAPS_KEY) { + const olaResults = await searchOlaMaps(cleanQuery, options); + if (olaResults.length > 0) return olaResults; + } + + // 2. High-speed Photon OSM autocomplete (sub-100ms, typo tolerant) + const photonResults = await searchPhoton(cleanQuery, options); + if (photonResults.length > 0) return photonResults; + + // 3. Fallback to scoped Nominatim + const nominatimResults = await searchNominatim(cleanQuery, options); + return nominatimResults; +} + +/** + * Reverse Geocode: Convert Coordinates to structured address + */ +export async function reverseGeocode(lat, lng) { + if (lat == null || lng == null) return null; + + // 1. Try Photon reverse first + try { + const res = await fetch(`${PHOTON_REVERSE_URL}?lat=${lat}&lon=${lng}`, { + headers: { Accept: 'application/json' } + }); + if (res.ok) { + const data = await res.json(); + if (data?.features?.length > 0) { + const item = data.features[0]; + const p = item.properties || {}; + const parts = [ + p.name, + p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', + p.district || p.suburb || p.neighbourhood || p.locality, + p.city, + p.state, + p.postcode, + p.country + ].filter(Boolean); + const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); + + return standardizePlace({ + formatted_address: formatted, + name: p.name || formatted.split(',')[0], + suburb: p.district || p.suburb || p.neighbourhood || p.locality || '', + city: p.city || p.county || '', + state: p.state || '', + postcode: p.postcode || '', + latitude: lat, + longitude: lng, + provider: 'photon_reverse' + }); + } + } + } catch (e) { + console.warn('[Geocoding] Photon reverse failed, trying Nominatim:', e); + } + + // 2. Fallback to Nominatim reverse + try { + const res = await fetch( + `${NOMINATIM_REVERSE_URL}?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`, + { + headers: { + Accept: 'application/json', + 'Accept-Language': 'en-GB,en;q=0.9', + 'User-Agent': 'DoormileConsole/1.0' + } + } + ); + if (res.ok) { + const r = await res.json(); + const addr = r.address || {}; + return standardizePlace({ + formatted_address: r.display_name || '', + name: r.display_name?.split(',')[0] || '', + suburb: addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '', + city: addr.city || addr.town || addr.village || addr.city_district || addr.county || '', + state: addr.state || '', + postcode: addr.postcode || '', + latitude: lat, + longitude: lng, + provider: 'nominatim_reverse' + }); + } + } catch (err) { + console.error('[Geocoding] Reverse geocode failed completely:', err); + } + + return standardizePlace({ + formatted_address: `Location (${Number(lat).toFixed(5)}, ${Number(lng).toFixed(5)})`, + name: 'Selected Pin', + latitude: lat, + longitude: lng, + provider: 'coordinates_only' + }); +} + +/** + * Direct forward geocode for single address string (used by bulk uploads / fallback) + */ +export async function geocodeAddress(address, options = {}) { + const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 }); + return suggestions.length > 0 ? suggestions[0] : null; +} diff --git a/src/lib/hubForm.js b/src/lib/hubForm.js new file mode 100644 index 0000000..97448c8 --- /dev/null +++ b/src/lib/hubForm.js @@ -0,0 +1,62 @@ +// ==============================|| Hub (base) form validation ||============================== // +// +// A hub in this console is a BASE to a rider. The backend hands six fields to +// the rider app as the place to go — id, name, address, pincode, latitude, +// longitude — and the app uses them to title the stop, show the gate address, +// and drive Navigate. +// +// Five of six is not "mostly there". Without coordinates Navigate does nothing; +// without an address the rider has nothing to read when they arrive. Only the +// name and the city id were ever validated here, which is how a live base ended +// up in the network with no address and no pin on the map. +// +// Lives in lib/ rather than inside the page so it can be tested as the pure +// decision it is, in the same shape as orderFlow and batchBucket. + +/** A coordinate that is present, numeric, in range, and not the 0,0 that means + * "never pinned". 0,0 is in the Gulf of Guinea; no Doormile base is there, so + * it is always an unset value rather than a location. */ +const coordinateError = (value, { min, max, axis }) => { + if (value === '' || value === null || value === undefined) return 'Navigate cannot work without a pin'; + const n = Number(value); + if (Number.isNaN(n)) return 'Enter a number'; + if (n === 0) return 'Navigate cannot work without a pin'; + if (n < min || n > max) return `${axis} must be between ${min} and ${max}`; + return null; +}; + +/** + * Validate the hub create/edit form. + * + * Returns `{ errors, values }`. `errors` is keyed by field name and is empty + * when the form is good; `values` carries the coordinates already coerced to + * numbers, so the caller does not re-parse what was just validated. + */ +export const validateHubForm = (form = {}) => { + const errors = {}; + + if (!String(form.hubname || '').trim()) errors.hubname = 'Give the hub a name'; + + if (form.applocationid === '' || form.applocationid === null || form.applocationid === undefined + || Number.isNaN(Number(form.applocationid))) { + errors.applocationid = 'Enter the numeric city id'; + } + + if (!String(form.address || '').trim()) errors.address = 'A rider needs an address to find the gate'; + if (!String(form.pincode || '').trim()) errors.pincode = 'Needed to tell two bases in one city apart'; + + const latError = coordinateError(form.latitude, { min: -90, max: 90, axis: 'Latitude' }); + if (latError) errors.latitude = latError; + const lngError = coordinateError(form.longitude, { min: -180, max: 180, axis: 'Longitude' }); + if (lngError) errors.longitude = lngError; + + return { + errors, + isValid: Object.keys(errors).length === 0, + values: { + latitude: Number(form.latitude), + longitude: Number(form.longitude), + applocationid: Number(form.applocationid) + } + }; +}; diff --git a/src/lib/orderFlow.js b/src/lib/orderFlow.js new file mode 100644 index 0000000..b9242ef --- /dev/null +++ b/src/lib/orderFlow.js @@ -0,0 +1,567 @@ +// ==============================|| Order flow classification ||============================== // +// +// A booking is two coordinate pairs and nothing else — `pickup*` and +// `delivery*`. The backend has never carried a field saying which END is a +// Doormile facility, so every consumer in the console silently assumed the +// pickup was always the hub. That assumption is baked into three places that +// break the moment it stops holding: +// +// • queries.js used the TENANT NAME as the pickup point's identity, so every +// order of a tenant claimed the same collection point wherever it actually +// was. +// • Dispatch's kitchen map grouped by that name and took its pin coordinates +// from whichever order happened to be first in the array. +// • buildTripPoints drew a rider's polyline as [one pickup, drop, drop, …]. +// +// This module is the single answer to "which end is the facility?", so those +// consumers agree with each other by construction rather than by three +// independent guesses. +// +// TWO SOURCES, in priority order: +// +// 1. An EXPLICIT `ordertype` on the booking. The console now sends this on +// create (see buildFlowFields below). Once the backend persists and +// returns it, it is authoritative and no geometry is consulted. +// 2. DERIVATION by proximity to a known facility. This is what makes the +// whole existing order history classify correctly today, with no backend +// change and no migration — an order created before this module existed +// still lands in the right bucket because its pickup coordinates still +// sit on top of the hub they were copied from. +// +// When neither resolves — no anchors loaded, or coordinates missing — the +// answer is FORWARD. That is deliberately the pre-existing behaviour: an +// unclassifiable order must look exactly like it did before this file was +// added, never like a new flow the operator did not create. + +/** Metres. A hub pickup copies the facility's own coordinates onto the + * booking, so the true distance is 0; the radius absorbs the operator who + * re-pinned the gate instead of the unit, and a geocoder that rounds. Wide + * enough for a compound, tight enough that the customer across the road is + * still a customer. */ +export const ANCHOR_RADIUS_M = 200; + +export const PICKUP_SOURCE = Object.freeze({ HUB: 'hub', CUSTOMER: 'customer' }); +export const DROP_TARGET = Object.freeze({ HUB: 'hub', CUSTOMER: 'customer' }); + +/** + * The backend's `pickup_source_type` vocabulary — what KIND of place a parcel + * is collected from. Four values where this module has two, and the difference + * is load-bearing rather than cosmetic. + * + * `PICKUP_SOURCE` above is a geometry question: is the collection point a + * facility, or a doorstep? A client's own kitchen answers HUB to that, because + * it is a facility with coordinates on file. But to a rider it is a merchant, + * not a base — different signage, different person to ask for, different screen + * heading. Collapsing the two is exactly what left the rider app titling every + * logistics pickup with the rider's own base name, so a collection at a shop + * and one at a house looked identical. + * + * So: the anchor's `kind` decides this, not `pickupSource`. + * + * These strings are the wire contract and must match the backend's constants + * exactly. The rider app renders `hub` as "Base"; that is its wording, not ours, + * and it never travels back up the wire. + */ +export const PICKUP_SOURCE_TYPE = Object.freeze({ + HUB: 'hub', + CUSTOMER: 'customer', + MERCHANT: 'merchant', + STORE: 'store' +}); + +const PICKUP_SOURCE_TYPE_VALUES = Object.values(PICKUP_SOURCE_TYPE); + +/** Operator-facing wording for a source type. */ +export const PICKUP_SOURCE_TYPE_LABEL = Object.freeze({ + [PICKUP_SOURCE_TYPE.HUB]: 'Base', + [PICKUP_SOURCE_TYPE.CUSTOMER]: 'Customer door', + [PICKUP_SOURCE_TYPE.MERCHANT]: 'Client site', + [PICKUP_SOURCE_TYPE.STORE]: 'Store' +}); + +/** An unrecognised type is shown as itself rather than dropped — the backend + * may add values, and a blank chip is worse than an unfamiliar word. */ +export const pickupSourceTypeLabel = (type) => + PICKUP_SOURCE_TYPE_LABEL[type] || type || PICKUP_SOURCE_TYPE_LABEL[PICKUP_SOURCE_TYPE.CUSTOMER]; + +const isKnownSourceType = (value) => PICKUP_SOURCE_TYPE_VALUES.includes(value); + +/** + * Which of the four the matched pickup anchor is. No anchor means the rider is + * going to somebody's door, and `customer` is the honest answer for that — a + * value, never an omission, because "no id because it is a front door" has to be + * distinguishable from "no id because nobody filled it in". + */ +export const sourceTypeForAnchor = (anchor) => { + if (!anchor) return PICKUP_SOURCE_TYPE.CUSTOMER; + if (anchor.kind === 'hub') return PICKUP_SOURCE_TYPE.HUB; + if (anchor.kind === 'tenantlocation') return PICKUP_SOURCE_TYPE.MERCHANT; + return PICKUP_SOURCE_TYPE.CUSTOMER; +}; + +export const FLOW = Object.freeze({ + /** hub → customer. The classic outbound run, and everything the console did + * before this module. */ + FORWARD: 'Forward', + /** customer → hub. "Customer pickup": the rider travels out to the + * customer's own door, collects there, and brings the parcel inbound. + * Returns, merchant collections, RTO. */ + REVERSE: 'Reverse', + /** customer → customer. Hyperlocal point-to-point; no facility is a physical + * stop on the trip, the hub only OWNS the order for zone and reporting. */ + P2P: 'P2P', + /** hub → hub. Trunk movement between facilities. Tripsheets already model + * this properly; it is named here so a booking that happens to look like it + * is not silently mislabelled as something else. */ + LINEHAUL: 'Linehaul' +}); + +const FLOW_BY_ENDPOINTS = { + [`${PICKUP_SOURCE.HUB}|${DROP_TARGET.CUSTOMER}`]: FLOW.FORWARD, + [`${PICKUP_SOURCE.CUSTOMER}|${DROP_TARGET.HUB}`]: FLOW.REVERSE, + [`${PICKUP_SOURCE.CUSTOMER}|${DROP_TARGET.CUSTOMER}`]: FLOW.P2P, + [`${PICKUP_SOURCE.HUB}|${DROP_TARGET.HUB}`]: FLOW.LINEHAUL +}; + +const ENDPOINTS_BY_FLOW = { + [FLOW.FORWARD]: { pickupSource: PICKUP_SOURCE.HUB, dropTarget: DROP_TARGET.CUSTOMER }, + [FLOW.REVERSE]: { pickupSource: PICKUP_SOURCE.CUSTOMER, dropTarget: DROP_TARGET.HUB }, + [FLOW.P2P]: { pickupSource: PICKUP_SOURCE.CUSTOMER, dropTarget: DROP_TARGET.CUSTOMER }, + [FLOW.LINEHAUL]: { pickupSource: PICKUP_SOURCE.HUB, dropTarget: DROP_TARGET.HUB } +}; + +/** Operator-facing wording. `Reverse` is the internal name; "Customer Pickup" + * is what the people running the board actually call it, so that is what the + * chip says. */ +export const FLOW_LABEL = Object.freeze({ + [FLOW.FORWARD]: 'Hub Pickup', + [FLOW.REVERSE]: 'Customer Pickup', + [FLOW.P2P]: 'Point to Point', + [FLOW.LINEHAUL]: 'Linehaul' +}); + +export const flowLabel = (flow) => FLOW_LABEL[flow] || FLOW_LABEL[FLOW.FORWARD]; + +/** True when the rider must travel out to a non-facility address to collect — + * i.e. the trip carries a first-mile leg that a hub run does not. Both the + * route builder and the deadhead pricing key off this, not off the flow name, + * so adding a fifth flow later cannot leave one of them behind. */ +export const hasCustomerPickup = (flow) => + ENDPOINTS_BY_FLOW[flow]?.pickupSource === PICKUP_SOURCE.CUSTOMER; + +/** True when the parcel ends at a facility rather than a doorstep. */ +export const hasHubDrop = (flow) => ENDPOINTS_BY_FLOW[flow]?.dropTarget === DROP_TARGET.HUB; + +const isValidFlow = (value) => Object.values(FLOW).includes(value); + +/** Accepts the loose casing a backend or a spreadsheet may send + * ("reverse", "REVERSE", "customer_pickup") and returns a canonical FLOW, or + * null when the value means nothing to us. Never throws — an unrecognised + * string must fall through to derivation, not blow up a page render. */ +export const normalizeFlow = (value) => { + const raw = String(value ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + if (!raw) return null; + const direct = Object.values(FLOW).find((f) => f.toLowerCase() === raw); + if (direct) return direct; + if (raw === 'customer_pickup' || raw === 'reverse_pickup' || raw === 'pickup') return FLOW.REVERSE; + if (raw === 'hub_pickup' || raw === 'delivery' || raw === 'outbound') return FLOW.FORWARD; + if (raw === 'point_to_point' || raw === 'hyperlocal' || raw === 'p2p') return FLOW.P2P; + if (raw === 'line_haul' || raw === 'trunk') return FLOW.LINEHAUL; + return null; +}; + +const toNum = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : NaN; +}; + +const EARTH_RADIUS_M = 6371000; + +const distanceMetres = (lat1, lon1, lat2, lon2) => { + const a1 = toNum(lat1); + const o1 = toNum(lon1); + const a2 = toNum(lat2); + const o2 = toNum(lon2); + if ([a1, o1, a2, o2].some(Number.isNaN)) return Infinity; + // A 0,0 coordinate is the backend's "unset", not the Gulf of Guinea. Treating + // it as a real point would put every unset pickup ~7000 km from every hub, + // which is harmless for the radius test but reads as a real measurement to + // anything that later logs this distance. + if ((a1 === 0 && o1 === 0) || (a2 === 0 && o2 === 0)) return Infinity; + const toRad = (d) => (d * Math.PI) / 180; + const dLat = toRad(a2 - a1); + const dLon = toRad(o2 - o1); + const h = + Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a1)) * Math.cos(toRad(a2)) * Math.sin(dLon / 2) ** 2; + return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); +}; + +export const metresBetween = distanceMetres; + +/** Decimal places used to bucket a doorstep collection into a grouping key. + * Four is ~11 m at this latitude — tight enough that two neighbouring houses + * stay separate, loose enough that one address geocoded twice lands in one + * bucket. Two collections at the SAME door must share a key: a real sheet + * (three home kitchens, thirteen drops) has six rows leaving one kitchen, and + * keying those per-booking would draw six pins stacked on one another. */ +const COLLECTION_POINT_PRECISION = 4; + +/** Can this coordinate pair be reasoned about at all? Missing, non-numeric and + * the backend's 0,0 "unset" all answer no. An endpoint that answers no can + * never be *proved* to be a customer's door — the absence of a hub match + * there means nothing — so classification must not read anything into it. */ +const isUsablePoint = (lat, lon) => { + const a = toNum(lat); + const o = toNum(lon); + if (Number.isNaN(a) || Number.isNaN(o)) return false; + return !(a === 0 && o === 0); +}; + +/** + * Fold the two facility lists the console knows about into one anchor set. + * + * `hubs` are Doormile's own sorting centres and delivery hubs (GET + * /admin/hubs). `tenantLocations` are a client's own origins — the kitchen, + * the depot, the branch (GET /admin/tenants/:id/locations). Both count as "a + * facility, not a customer's door", and an order picked up at either one is a + * hub pickup as far as the board is concerned. + * + * Anchors without usable coordinates are dropped rather than kept with NaN: + * a hub nobody geocoded cannot confirm OR deny anything, and keeping it would + * make every distance comparison against it Infinity anyway. + */ +/** How far a facility can sit from a hub and still be served by it. 35 km is + * not arbitrary — it is the radius ZoneContext already uses to decide whether + * an order belongs to a hub's zone. Deriving a different number here would let + * an order be stamped with one hub and then filtered into another. */ +export const SERVICING_HUB_RADIUS_M = 35000; + +/** The hub that services a point, or null. Nearest wins, for the same reason + * matchAnchor picks nearest: array order is not a fact about geography. */ +const nearestHubId = (lat, lon, hubAnchors) => { + let bestId = null; + let bestDist = Infinity; + (hubAnchors || []).forEach((h) => { + if (h.hubid == null) return; + const d = distanceMetres(lat, lon, h.lat, h.lon); + if (d <= SERVICING_HUB_RADIUS_M && d < bestDist) { + bestId = h.hubid; + bestDist = d; + } + }); + return bestId; +}; + +export const buildAnchors = ({ hubs = [], tenantLocations = [] } = {}) => { + const anchors = []; + const hubAnchors = []; + + (hubs || []).forEach((h) => { + const lat = toNum(h?.latitude ?? h?.lat); + const lon = toNum(h?.longitude ?? h?.lng ?? h?.lon); + if (Number.isNaN(lat) || Number.isNaN(lon)) return; + const id = h?.hubid ?? h?.id; + const anchor = { + key: `hub:${id}`, + hubid: id ?? null, + tenantid: null, + name: h?.hubname || h?.name || h?.address || `Hub #${id}`, + address: h?.address || '', + lat, + lon, + kind: 'hub' + }; + anchors.push(anchor); + hubAnchors.push(anchor); + }); + + (tenantLocations || []).forEach((l) => { + const lat = toNum(l?.latitude ?? l?.lat); + const lon = toNum(l?.longitude ?? l?.lng ?? l?.lon); + if (Number.isNaN(lat) || Number.isNaN(lon)) return; + const id = l?.tenantlocationid ?? l?.locationid ?? l?.id; + anchors.push({ + key: `loc:${id}`, + // A tenant location record carries NO hubid — the API simply has no such + // field on it. Left as null, every order originating at a client's own + // kitchen went to the backend with no servicing hub at all, which is the + // whole reason zone attribution still had to fall back to matching hub + // names against concatenated address text. Since a location is physically + // served by whichever hub covers its city, the nearest one within + // SERVICING_HUB_RADIUS_M is that answer. + hubid: l?.hubid ?? nearestHubId(lat, lon, hubAnchors), + tenantid: l?.tenantid ?? null, + name: l?.locationname || l?.name || l?.address || `Location #${id}`, + address: l?.address || '', + lat, + lon, + kind: 'tenantlocation' + }); + }); + + return anchors; +}; + +/** + * Nearest anchor within ANCHOR_RADIUS_M, or null. + * + * Nearest rather than first-within-radius: two facilities can share a compound + * (a sorting centre and a client depot at the same address is normal), and + * picking whichever was earlier in the array would name the pin inconsistently + * depending on the order /admin/hubs happened to return. + */ +export const matchAnchor = (lat, lon, anchors = [], radiusM = ANCHOR_RADIUS_M) => { + let best = null; + let bestDist = Infinity; + (anchors || []).forEach((a) => { + const d = distanceMetres(lat, lon, a.lat, a.lon); + if (d <= radiusM && d < bestDist) { + best = a; + bestDist = d; + } + }); + return best; +}; + +const pickupCoords = (b) => ({ + lat: b?.pickuplatitude ?? b?.pickuplat ?? b?.pickup_lat, + lon: b?.pickuplongitude ?? b?.pickuplong ?? b?.pickup_lon +}); + +const dropCoords = (b) => ({ + lat: b?.deliverylatitude ?? b?.droplat ?? b?.deliverylat ?? b?.delivery_lat, + lon: b?.deliverylongitude ?? b?.droplon ?? b?.deliverylong ?? b?.delivery_lon +}); + +/** + * Classify one booking. + * + * Returns, always, a complete shape — callers render from it directly and must + * never have to null-check a field into a blank badge. + * + * flow one of FLOW + * pickupSource PICKUP_SOURCE — which kind of place the parcel leaves + * dropTarget DROP_TARGET — which kind of place it arrives at + * originAnchor the matched facility at the pickup end, or null + * destAnchor the matched facility at the drop end, or null + * pickupPointKey STABLE grouping key for the collection point. This is the + * field Dispatch groups its map pins by. For a facility it + * is the anchor key, so every order off that hub shares one + * pin; for a customer pickup it is per-booking, because two + * customer doors are two different places and merging them + * is exactly the bug this replaces. + * pickupPointName what to show for that point + * servicingHubId the hub that OWNS the order for zone filtering and + * reporting — deliberately NOT "the hub we pick up from", + * because on a P2P run there is no such hub and the order + * still belongs to a zone. + * derived true when geometry decided it, false when the booking + * carried an explicit ordertype. Useful when an operator + * asks why a row is labelled the way it is. + */ +export const classifyBooking = (booking, anchors = []) => { + const b = booking || {}; + const p = pickupCoords(b); + const d = dropCoords(b); + + const originAnchor = matchAnchor(p.lat, p.lon, anchors); + const destAnchor = matchAnchor(d.lat, d.lon, anchors); + + const explicit = normalizeFlow(b.ordertype ?? b.order_type ?? b.flowtype ?? b.flow_type); + + // The backend now stores what kind of place a booking is collected from, and + // returns it on the row. When it is there it is the answer — it was recorded + // at creation by whoever actually knew, rather than re-inferred from two + // coordinates every time a page loads. Geometry stays as the classifier for + // the whole order history created before the column existed. + const storedSourceType = isKnownSourceType(b.pickup_source_type ?? b.pickupsourcetype) + ? b.pickup_source_type ?? b.pickupsourcetype + : null; + const storedPickupHubId = b.pickuphubid ?? b.pickup_hub_id ?? null; + + let flow; + let pickupSource; + let dropTarget; + + if (explicit) { + flow = explicit; + ({ pickupSource, dropTarget } = ENDPOINTS_BY_FLOW[explicit]); + } else if (!anchors?.length || !isUsablePoint(p.lat, p.lon)) { + // Either no facility list loaded, or the booking has no usable pickup + // coordinates. Geometry can prove nothing in either case, so hold the + // pre-existing behaviour rather than inventing a flow from silence — a + // pickup that failed to geocode is a data problem, not a customer pickup, + // and labelling it as one would put a phantom collection pin on the map. + flow = FLOW.FORWARD; + ({ pickupSource, dropTarget } = ENDPOINTS_BY_FLOW[FLOW.FORWARD]); + } else { + pickupSource = originAnchor ? PICKUP_SOURCE.HUB : PICKUP_SOURCE.CUSTOMER; + // Same reasoning at the drop end: an un-geocoded drop cannot be shown to + // be a facility, and the overwhelmingly common case is a customer. + dropTarget = destAnchor && isUsablePoint(d.lat, d.lon) ? DROP_TARGET.HUB : DROP_TARGET.CUSTOMER; + flow = FLOW_BY_ENDPOINTS[`${pickupSource}|${dropTarget}`]; + } + + const bookingKey = b.bookingid ?? b.id ?? b.orderheaderid ?? b.bookingno ?? 'unknown'; + + // Stored value wins; otherwise the matched anchor's kind decides, which is the + // same judgement the create form makes when it stamps the field. + const pickupSourceType = storedSourceType ?? sourceTypeForAnchor(originAnchor); + + const isHubPickup = pickupSource === PICKUP_SOURCE.HUB; + + // A facility groups by its anchor. A doorstep groups by WHERE IT IS, so two + // bookings collected at one door are one visit and one pin, while two + // different doors stay apart. The booking id is only the last resort, for a + // collection whose coordinates never resolved — those must not merge with + // each other, because nothing has shown them to be the same place. + const pickupPointKey = + isHubPickup && originAnchor + ? originAnchor.key + : isUsablePoint(p.lat, p.lon) + ? `cust:${Number(p.lat).toFixed(COLLECTION_POINT_PRECISION)},${Number(p.lon).toFixed( + COLLECTION_POINT_PRECISION + )}` + : `cust:${bookingKey}`; + + const pickupPointName = isHubPickup + ? originAnchor?.name || b.pickupaddress || 'Hub' + : b.pickupcontactname || b.pickup_name || b.pickupaddress || 'Customer Pickup'; + + // Explicit ids first, matched anchor second, destination facility third. The + // last one matters for a customer pickup that is inbound to a hub: nothing + // on the pickup end names a facility, but the order plainly belongs to the + // hub it is being brought to. + const servicingHubId = + b.sourcehubid ?? + b.hubid ?? + (originAnchor?.kind === 'hub' ? originAnchor.hubid : null) ?? + originAnchor?.hubid ?? + (destAnchor?.kind === 'hub' ? destAnchor.hubid : null) ?? + destAnchor?.hubid ?? + null; + + return { + flow, + flowLabel: flowLabel(flow), + pickupSource, + /** One of PICKUP_SOURCE_TYPE — the backend's four-value vocabulary, not the + * facility/doorstep binary above. */ + pickupSourceType, + pickupSourceTypeLabel: pickupSourceTypeLabel(pickupSourceType), + /** The base a Base -> Customer booking is collected FROM, when the row + * names one. Null for every other kind of pickup. */ + pickupHubId: + storedPickupHubId ?? + (pickupSourceType === PICKUP_SOURCE_TYPE.HUB ? originAnchor?.hubid ?? null : null), + /** True when the type came off the row rather than out of geometry. */ + sourceTypeFromServer: storedSourceType != null, + dropTarget, + originAnchor, + destAnchor, + pickupPointKey, + pickupPointName, + servicingHubId, + derived: !explicit + }; +}; + +/** + * The flow fields to merge into a create-booking payload. + * + * Sent on every create even though the backend may not store them yet: an + * unknown key is ignored server-side, and the day it IS stored, every order + * created from that point carries its type explicitly instead of relying on + * the console re-deriving it from coordinates on each page load. + * + * `sourcehubid`/`hubid` are the pair that fixes zone attribution. The console + * has always READ `b.hubid`/`b.sourcehubid` off a booking (queries.js) and + * never once written them, which is why ZoneContext had to fall back to + * matching hub names against concatenated address text. + */ +export const buildFlowFields = ({ + flow, + servicingHubId, + tenantLocationId, + pickupAnchor = null, + pickupSourceType +} = {}) => { + const resolved = isValidFlow(flow) ? flow : FLOW.FORWARD; + const fields = { ordertype: resolved }; + if (servicingHubId != null && servicingHubId !== '') { + fields.sourcehubid = Number(servicingHubId) || servicingHubId; + fields.hubid = fields.sourcehubid; + } + if (tenantLocationId != null && tenantLocationId !== '') { + fields.tenantlocationid = Number(tenantLocationId) || tenantLocationId; + } + + // pickup_source_type is stored and returned by the backend, and the rider app + // reads it to title the stop. It is sent on EVERY create, "customer" + // included — an omission would be read as "nobody filled this in", which is a + // different fact from "this is somebody's front door". + // + // A caller-supplied type wins only if it is one of the known values; anything + // else falls back to what the matched anchor says, so a typo cannot put a word + // on the wire that no reader has a meaning for. + const resolvedType = isKnownSourceType(pickupSourceType) + ? pickupSourceType + : sourceTypeForAnchor(pickupAnchor); + fields.pickup_source_type = resolvedType; + + // Only a base pickup names a base. Sending pickuphubid alongside a merchant + // or customer type would tell the backend this is a Base -> Customer run when + // it is not. + if (resolvedType === PICKUP_SOURCE_TYPE.HUB) { + const baseId = pickupAnchor?.kind === 'hub' ? pickupAnchor.hubid : null; + if (baseId != null && baseId !== '') { + fields.pickuphubid = Number(baseId) || baseId; + } + } + + return fields; +}; + +/** + * Derive the flow from what the CREATE form knows, before a booking exists. + * + * BOTH ends are measured, and geometry outranks the operator's toggle. + * + * That is not pedantry — it is what keeps the write path and the read path + * telling the same story. Whatever this stamps on the payload, classifyBooking + * will later re-derive from the very same coordinates when the board loads the + * booking back. If the two used different rules the same order would read "Point + * to Point" in one place and "Hub Pickup" in another, which is precisely the + * kind of disagreement this module exists to end. + * + * A real case makes it concrete: a tenant's bulk file collects from three home + * kitchens, one of which IS their registered business location. Those rows are + * hub pickups however the operator set the toggle, because that is what the + * coordinates say — and because the board will say so regardless. + * + * The toggle still decides when geometry cannot: a pickup with no usable + * coordinates falls back to what the operator declared. + */ +export const flowForDraft = ({ + pickupSource, + pickupLat, + pickupLng, + dropLat, + dropLng, + anchors = [] +} = {}) => { + const originAnchor = matchAnchor(pickupLat, pickupLng, anchors); + const destAnchor = matchAnchor(dropLat, dropLng, anchors); + + const source = isUsablePoint(pickupLat, pickupLng) + ? originAnchor + ? PICKUP_SOURCE.HUB + : PICKUP_SOURCE.CUSTOMER + : pickupSource === PICKUP_SOURCE.CUSTOMER + ? PICKUP_SOURCE.CUSTOMER + : PICKUP_SOURCE.HUB; + + const target = destAnchor && isUsablePoint(dropLat, dropLng) ? DROP_TARGET.HUB : DROP_TARGET.CUSTOMER; + + return { flow: FLOW_BY_ENDPOINTS[`${source}|${target}`], originAnchor, destAnchor }; +}; diff --git a/src/lib/routingSummary.js b/src/lib/routingSummary.js new file mode 100644 index 0000000..864f2a3 --- /dev/null +++ b/src/lib/routingSummary.js @@ -0,0 +1,78 @@ +// ==============================|| Routing decision, in words ||============================== // +// +// `GET /admin/bookings/:id` returns a `routing` block: the decision the backend +// took about where a parcel goes, and the inputs it took it from. This turns +// that into the sentences the order drawer shows. +// +// It exists so support can answer "why does the rider's screen say hand over at +// a base instead of deliver to the customer?" as a lookup instead of +// reconstructing a pincode rule by hand on the phone. +// +// TWO RULES THIS FILE EXISTS TO HOLD: +// +// 1. A PROJECTION IS NOT A DECISION. Before pickup, nothing has been decided — +// `routing.decided` is false and what is shown is what WILL happen. Saying +// "this went via a base" about a parcel nobody has collected yet is a +// wrong answer delivered confidently, which is worse than no answer. +// +// 2. THE WIRE SAYS HUB, PEOPLE SAY BASE. The translation happens here, at the +// edge, and never travels back up: every value sent to the backend keeps +// its wire spelling. + +/** What the rider does next with the parcel. Keys are the backend's wire + * values; an unrecognised one is shown as itself rather than swallowed — + * the backend may add actions, and a blank row is worse than a raw word. */ +export const NEXT_ACTION_LABEL = Object.freeze({ + pickup: 'Collect from the pickup point', + inward_at_hub: 'Carry to a base and hand over', + start_delivery: 'Start the delivery run', + deliver: 'Deliver to the receiver', + handed_to_hub: 'Handed over at the base', + none: 'Nothing further for the rider' +}); + +export const nextActionLabel = (action) => NEXT_ACTION_LABEL[action] || action || '—'; + +/** Why the parcel is going the way it is, in one sentence an operator can read + * to a rider over the phone. */ +export const routingReason = (routing) => { + if (!routing) return ''; + return routing.is_hyperlocal + ? 'Same postal area — the collecting rider carries it straight to the receiver.' + : 'Different postal area — it goes through a base rather than direct to the receiver.'; +}; + +/** The from → to line, with the honesty caveat when nothing has been decided + * yet. A booking not yet collected has no routing FACT, only a forecast. */ +export const routingRoute = (routing) => { + if (!routing) return ''; + const from = routing.from_pincode || '—'; + const to = routing.destination_pincode || '—'; + const line = `${from} → ${to}`; + return routing.decided ? line : `${line} · not yet collected, so this is what will happen, not what has`; +}; + +/** The base a parcel is routed to, as one readable line. Null when no base is + * involved — which is the correct answer for a hyperlocal parcel, not a gap. */ +export const baseLine = (base) => { + if (!base || !base.name) return null; + return base.pincode ? `${base.name} · ${base.pincode}` : base.name; +}; + +/** + * Everything the drawer renders, in one call, so the component holds no + * decisions of its own. + */ +export const summariseRouting = (routing) => { + if (!routing) return null; + return { + decided: Boolean(routing.decided), + reason: routingReason(routing), + route: routingRoute(routing), + nextAction: nextActionLabel(routing.next_action), + base: baseLine(routing.next_hub), + baseAddress: routing.next_hub?.address || null, + consignmentState: routing.consignment_state || '—', + inwardedAt: routing.inwardedat || null + }; +}; diff --git a/src/main.jsx b/src/main.jsx index abac273..ac3092d 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -7,5 +7,5 @@ import '@/index.css' ReactDOM.createRoot(document.getElementById('root')).render( -) +); diff --git a/src/pages/doormile/bookings/Bookings.jsx b/src/pages/doormile/bookings/Bookings.jsx new file mode 100644 index 0000000..1e7f5f4 --- /dev/null +++ b/src/pages/doormile/bookings/Bookings.jsx @@ -0,0 +1,592 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { + Ban, Bike, BookMarked, CheckCircle2, Clock, FileSpreadsheet, + Package, PackageSearch, RefreshCw, Sparkles, UserCheck, XCircle, +} from 'lucide-react'; + +import { + Alert, Button, DataTable, Drawer, EmptyState, Grid, KpiCard, + Modal, PageHeader, Stack, StatusBadge, Surface, Tabs, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ds'; +import { ListToolbar } from '@/components/doormile/ListToolbar'; +import { OpenToast, messageOf } from '@/api/doormile/notify'; +import { drainBookings } from '@/lib/assistant/scan'; +import { + useBooking, useAssignMilerToBooking, useRiderOptions, + useCancelBooking, useBulkCancelBookings, +} from '@/lib/doormileHooks'; +import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints'; +import { + BOOKING_TABS, bookingsForTab, customerAppBookings, + quotedPrice, serviceOptionOf, serviceType, +} from '@/lib/customerAppBookings'; +import { currency, exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; +import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; +import { pickupSourceTypeLabel } from '@/lib/orderFlow'; + +/** + * Bookings — customer app (doormile_cx) bookings management with tab separation + * (Created, Rider Assigned, Delivered, Cancelled) and bulk rider assignment. + */ + +const EXPORT_COLUMNS = [ + { key: 'bookingno', header: 'Booking' }, + { key: 'status', header: 'Status' }, + { key: 'pickupaddress', header: 'Pickup' }, + { key: 'pickuppincode', header: 'Pickup pincode' }, + { key: 'deliveryaddress', header: 'Drop' }, + { key: 'deliverypincode', header: 'Drop pincode' }, + { key: 'service', header: 'Service', value: (row) => serviceType(row) }, + { key: 'price', header: 'Quoted', value: (row) => quotedPrice(row) }, + { key: 'createdat', header: 'Booked' }, +]; + +/** One booking in full. Fetched on open rather than up front. */ +function BookingDrawer({ bookingId, onClose }) { + const { data: booking, isLoading, refetch: refetchBooking } = useBooking(bookingId); + const { data: riders = [] } = useRiderOptions(); + const assignMutation = useAssignMilerToBooking(); + const [selectedRiderId, setSelectedRiderId] = useState(''); + + const parcels = booking?.parcels || []; + const payments = booking?.payments || []; + const price = serviceOptionOf(booking); + + const assignedUserId = booking?.assignedmileruserid || booking?.mileruserid; + const assignedRider = useMemo(() => { + if (!assignedUserId) return null; + return riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(assignedUserId)); + }, [assignedUserId, riders]); + + const handleAssign = async () => { + if (!selectedRiderId || !bookingId) return; + const target = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId)); + const targetUserId = target?.userid || target?.mileruserid || target?.id || selectedRiderId; + assignMutation.mutate( + { id: bookingId, mileruserid: Number(targetUserId) }, + { + onSuccess: () => { + setSelectedRiderId(''); + refetchBooking(); + }, + } + ); + }; + + const isAssignable = booking?.status !== 'Cancelled' && booking?.status !== 'Delivered'; + + return ( + !next && onClose()} + title={booking?.bookingno ? `Booking ${booking.bookingno}` : 'Booking'} + icon={PackageSearch} + size="lg" + > + {isLoading ? ( +

    Loading booking…

    + ) : !booking ? ( +

    This booking could not be loaded.

    + ) : ( + + +
    + {[ + ['Status', ], + ['Booked', formatDoormileTimestamp(booking.createdat)], + ['Collected from', pickupSourceTypeLabel(booking.pickup_source_type)], + ['Consignment', orDash(booking.consignmentid)], + ['Assigned Rider', assignedRider?.displayname || assignedRider?.name || (assignedUserId ? `Rider #${assignedUserId}` : 'Unassigned')], + ].map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    +
    + + {isAssignable && ( + +

    + {assignedUserId ? 'Reassign Rider' : 'Assign Rider'} +

    +
    +
    + +
    + +
    +
    + )} + +
    +

    Pickup

    +

    {orDash(booking.pickupaddress)}

    +

    {orDash(booking.pickuppincode)}

    +
    + +
    +

    Drop

    +

    {orDash(booking.deliveryaddress)}

    +

    {orDash(booking.deliverypincode)}

    +
    + +
    +

    Price

    + {!price ? ( +

    No price was quoted for this booking.

    + ) : ( + +
    + {[ + ['Service', orDash(price.servicetype)], + ['Quoted', currency(price.estimatedprice)], + ['Expected delivery', formatDoormileTimestamp(price.estimateddeliveryat)], + ['SLA due', formatDoormileTimestamp(price.sladueat)], + ].map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    +
    + )} +
    + +
    +

    + Parcels {parcels.length ? `· ${parcels.length}` : ''} +

    + {parcels.length === 0 ? ( +

    No parcel detail recorded.

    + ) : ( + + {parcels.map((parcel, i) => ( + +

    {orDash(parcel.itemcategory)}

    +

    + {orDash(parcel.itemdescription)} + {parcel.weight ? ` · ${parcel.weight} kg` : ''} + {parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''} +

    +
    + ))} +
    + )} +
    + + {payments.length > 0 && ( +
    +

    Payments

    + + {payments.map((p, i) => ( + +

    + {currency(p.amount)} · {orDash(p.paymentmode)} +

    +

    {orDash(p.paymentstatus)}

    +
    + ))} +
    +
    + )} +
    + )} +
    + ); +} + +export default function Bookings() { + const [tab, setTab] = useState('all'); + const [search, setSearch] = useState(''); + const [openId, setOpenId] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const [manualAssignOpen, setManualAssignOpen] = useState(false); + const [cancelOpen, setCancelOpen] = useState(false); + const [selectedRiderId, setSelectedRiderId] = useState(''); + const [isAssigning, setIsAssigning] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + + const queryClient = useQueryClient(); + const debouncedSearch = useDebouncedValue(search, 250); + const { data: riders = [] } = useRiderOptions(); + + const { data: scan, isLoading, isFetching, isError, error, refetch } = useQuery({ + queryKey: ['doormile', 'bookings', 'all'], + queryFn: () => drainBookings({ cached: false }), + refetchInterval: 15_000, + refetchOnMount: 'always', + }); + + const bookings = useMemo(() => customerAppBookings(scan?.rows), [scan]); + + // Tab counts + const counts = useMemo(() => { + return { + all: bookings.length, + created: bookingsForTab(bookings, 'created').length, + assigned: bookingsForTab(bookings, 'assigned').length, + delivered: bookingsForTab(bookings, 'delivered').length, + cancelled: bookingsForTab(bookings, 'cancelled').length, + }; + }, [bookings]); + + const rows = useMemo( + () => + bookingsForTab(bookings, tab).filter((b) => + matchesQuery(b, ['bookingno', 'pickupaddress', 'deliveryaddress', 'status'], debouncedSearch) + ), + [bookings, tab, debouncedSearch] + ); + + const selectedOrders = useMemo( + () => rows.filter((row) => selectedIds.includes(row.bookingid)), + [rows, selectedIds] + ); + + // Clear selection when tab changes + useEffect(() => { + setSelectedIds([]); + }, [tab]); + + const stats = useMemo(() => { + const created = counts.created; + const value = bookings.reduce((sum, b) => sum + quotedPrice(b), 0); + return { total: bookings.length, created, value }; + }, [bookings, counts]); + + const riderLookup = useMemo(() => { + const map = new Map(); + riders.forEach((r) => { + const id = String(r.userid || r.mileruserid || r.id); + map.set(id, r.displayname || r.name || r.label); + }); + return map; + }, [riders]); + + // Bulk manual assign handler + const handleBulkManualAssign = async () => { + if (!selectedRiderId || !selectedIds.length) { + OpenToast('Please select a rider first', 'warning'); + return; + } + const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId)); + if (!targetRider) { + OpenToast('Please select a valid rider', 'warning'); + return; + } + const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id; + setIsAssigning(true); + + try { + await Promise.all( + selectedIds.map((bookingId) => + assignMilerToBooking(bookingId, { mileruserid: Number(riderUserId) }) + ) + ); + + if (targetRider.milerprofileid) { + notifyMiler( + targetRider.milerprofileid, + 'DoormileXpress', + `${selectedIds.length} customer booking(s) assigned to you` + ).catch(() => {}); + } + + OpenToast( + `${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'rider'}`, + 'success' + ); + + queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] }); + refetch(); + setSelectedIds([]); + setManualAssignOpen(false); + setSelectedRiderId(''); + } catch (err) { + OpenToast(messageOf(err, 'Failed to assign orders'), 'error', 4000); + } finally { + setIsAssigning(false); + } + }; + + // Bulk cancel handler + const handleBulkCancel = async () => { + if (!selectedIds.length) return; + setIsCancelling(true); + try { + const { bulkCancelBookings } = await import('@/api/doormile/endpoints'); + await bulkCancelBookings(selectedIds); + OpenToast(`${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} cancelled`, 'success'); + queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] }); + refetch(); + setSelectedIds([]); + setCancelOpen(false); + } catch (err) { + OpenToast(messageOf(err, 'Failed to cancel orders'), 'error', 4000); + } finally { + setIsCancelling(false); + } + }; + + const columns = useMemo( + () => [ + { + key: 'booking', + header: 'Booking', + sortable: true, + accessor: (row) => ( +
    +

    {orDash(row.bookingno)}

    +

    {formatDoormileTimestamp(row.createdat)}

    +
    + ), + }, + { + key: 'route', + header: 'Route', + accessor: (row) => ( +
    +

    + {orDash(row.pickupaddress)} +

    +

    + → {orDash(row.deliveryaddress)} +

    +
    + ), + }, + { + key: 'rider', + header: 'Rider', + hideBelow: 'md', + accessor: (row) => { + const riderId = String(row.assignedmileruserid || row.mileruserid || ''); + const name = riderLookup.get(riderId); + if (name) { + return {name}; + } + if (riderId) { + return Rider #{riderId}; + } + return Unassigned; + }, + }, + { key: 'service', header: 'Service', hideBelow: 'md', accessor: (row) => orDash(serviceType(row)) }, + { + key: 'price', + header: 'Price', + sortable: true, + accessor: (row) => (quotedPrice(row) ? currency(quotedPrice(row)) : '—'), + }, + { + key: 'status', + header: 'Status', + accessor: (row) => , + }, + ], + [riderLookup] + ); + + const canAssign = tab === 'created' && selectedOrders.length > 0; + + return ( + + refetch()} loading={isFetching}> + Refresh + + } + /> + + {isError && {messageOf(error, 'Could not load bookings')}} + + {scan?.truncated && ( + + Showing the most recent {Number(scan.scanned || 0).toLocaleString('en-IN')} of{' '} + {Number(scan.total || 0).toLocaleString('en-IN')} bookings. The counts below are a + floor, not a total. + + )} + + + + + + + +
    +
    + ({ + value: t.value, + label: t.label, + count: counts[t.value] ?? 0, + }))} + value={tab} + onChange={setTab} + className="shrink-0" + /> +
    + +
    + exportRows(rows, EXPORT_COLUMNS, `customer-bookings-${tab}`)} + exportDisabled={rows.length === 0} + /> +
    +
    + + row.bookingid} + loading={isLoading} + refreshing={isFetching && !isLoading} + selectedIds={tab === 'created' ? selectedIds : undefined} + onSelectionChange={tab === 'created' ? setSelectedIds : undefined} + onRowClick={(row) => setOpenId(row.bookingid)} + pageSize={25} + isFiltered={Boolean(debouncedSearch)} + onClearFilters={() => setSearch('')} + emptyState={ + debouncedSearch ? undefined : ( + entry.value === tab)?.label.toLowerCase()} bookings`} + description="Customer app bookings will appear here as they are placed." + /> + ) + } + /> + + {/* Floating Bulk Action Bar */} + {canAssign && ( + +
    + {selectedOrders.length} selected +
    + + + + +
    + )} + + {/* Bulk Assign Modal */} + +
    +
    + + +
    + +
    + + +
    +
    +
    + + {/* Cancel Confirmation Modal */} + +
    + + +
    +
    + + setOpenId(null)} /> +
    + ); +} diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx index 3d7d9cf..7050302 100644 --- a/src/pages/doormile/deliveries/Deliveries.jsx +++ b/src/pages/doormile/deliveries/Deliveries.jsx @@ -16,6 +16,8 @@ import { useZone } from '@/lib/ZoneContext'; import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket'; import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp'; import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; +import { pickupSourceTypeLabel } from '@/lib/orderFlow'; +import { summariseRouting } from '@/lib/routingSummary'; /** * Deliveries — orders that have moved past merely being created. @@ -640,6 +642,59 @@ export default function Deliveries() { ); } +/** + * How this booking was routed, and on what. + * + * Backend decides the route; this only reports the decision and the inputs it + * was made from, so support can answer "why does the rider's screen say hand + * over at a base instead of deliver?" without reconstructing a pincode rule by + * hand. + * + * Before pickup the decision has not been taken — `decided` is false and what is + * shown is a projection from the captured from/to, labelled as such. A number + * presented as a decision when it is only a forecast is worse than no number. + * + * Wording note: the wire says hub, operators and riders say base. The + * translation happens here, at the edge, and never travels back up. + */ +function RoutingSummary({ routing }) { + const s = summariseRouting(routing); + if (!s) return null; + + return ( +
    +

    Routing

    + + +

    {s.reason}

    +

    {s.route}

    + +
    + {[ + ['Collected from', pickupSourceTypeLabel(routing.pickup_source_type)], + ['Parcel state', s.consignmentState], + ['Next step', s.nextAction], + ['Base', s.base || '—'], + ].map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    + + {s.baseAddress ?

    {s.baseAddress}

    : null} + {s.inwardedAt ? ( +

    + Received at the base {formatDoormileTimestamp(s.inwardedAt)} +

    + ) : null} +
    +
    +
    + ); +} + /** * What is actually in the parcel. * @@ -687,14 +742,40 @@ function OrderDetailDrawer({ row, onClose }) {

    Pickup

    -

    {orDash(row?.pickupaddress)}

    +

    + {orDash(booking?.routing?.from_address || row?.pickupaddress)} +

    + {booking?.routing ? ( +

    + {[ + pickupSourceTypeLabel(booking.routing.pickup_source_type), + booking.routing.pickup_source_name, + booking.routing.from_pincode + ] + .filter(Boolean) + .join(' · ')} +

    + ) : null}

    Drop

    -

    {orDash(row?.deliveryaddress)}

    +

    + {orDash(booking?.routing?.to_address || row?.deliveryaddress)} +

    + {booking?.routing?.destination_pincode ? ( +

    {booking.routing.destination_pincode}

    + ) : null}
    + {/* Why this parcel is going where it is going. + A rider rings asking why his screen says hand over at a base + instead of deliver to the customer; without this, answering meant + reconstructing the routing rule from two pincodes by hand. The + backend decides the route and reports it here with the inputs it + used, so the answer is a lookup. */} + {booking?.routing ? : null} +

    Parcels {parcels.length ? `· ${parcels.length}` : ''} diff --git a/src/pages/doormile/dispatch/Dispatch.css b/src/pages/doormile/dispatch/Dispatch.css index 2b65d5b..2387a44 100644 --- a/src/pages/doormile/dispatch/Dispatch.css +++ b/src/pages/doormile/dispatch/Dispatch.css @@ -2886,6 +2886,30 @@ letter-spacing: 0.05em; } +/* Flow chips. The class is built from the raw flow name + (`type-${ordertype.toLowerCase()}`), so these four selectors track FLOW in + lib/orderFlow.js. `type-forward` has no rule on purpose: an ordinary hub run + never renders a chip at all (see flowChipLabel), and a rule for it would only + fire if that ever changed. The colours match their map markers — violet is a + doorstep collection in both places. */ +.dispatch-container .zone-order-chip.type-reverse { + background: rgba(139, 92, 246, 0.1); + border-color: rgba(139, 92, 246, 0.3); + color: #7c3aed; +} + +.dispatch-container .zone-order-chip.type-p2p { + background: rgba(14, 165, 233, 0.1); + border-color: rgba(14, 165, 233, 0.3); + color: #0284c7; +} + +.dispatch-container .zone-order-chip.type-linehaul { + background: rgba(100, 116, 139, 0.12); + border-color: rgba(100, 116, 139, 0.3); + color: #475569; +} + .dispatch-container .kitchen-transition { padding: 12px; background: var(--kitchen-soft); @@ -4620,6 +4644,16 @@ box-shadow: 0 0 20px rgba(245, 158, 11, 0.8), 0 0 40px rgba(245, 158, 11, 0.4); } +/* A doorstep collection is not a kitchen. Same shape and size — it is still a + collection point and still groups a rider's stops — but violet rather than + amber, so a dispatcher scanning the map can tell "load here, this is ours" + from "go out and collect here" without opening the pin. The box-shadow is + pinned inline by createKitchenIcon() (it carries the matching violet), which + is why only the fill is set here. */ +.dispatch-container .kitchen-mark.is-customer-pickup { + background: #8b5cf6; +} + /* Focused kitchen marker — larger, brighter, with a pulsing halo so users never lose sight of the kitchen they drilled into. */ .dispatch-container .kitchen-mark.is-focused { diff --git a/src/pages/doormile/dispatch/Dispatch.jsx b/src/pages/doormile/dispatch/Dispatch.jsx index ba85308..6914de5 100644 --- a/src/pages/doormile/dispatch/Dispatch.jsx +++ b/src/pages/doormile/dispatch/Dispatch.jsx @@ -61,6 +61,7 @@ import { OpenToast } from 'components/third-party/OpenToast'; import { BATCHES as SHARED_BATCHES } from 'utils/batchBucket'; import { getConsignmentLogs } from 'pages/api/doormileApi'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; +import { FLOW } from '@/lib/orderFlow'; import { onActivate, getStatusStyle, @@ -156,6 +157,38 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF // the same order under two different keys depending on which code path ran). const pickupName = (o) => o.pickupcustomer || o.kitchen_key || o.locationname || o.pickuplocation || o.pickupsuburb || null; +// The STABLE identity of an order's collection point — what everything below +// groups on. The display NAME is not that identity, for two reasons: +// +// • Two different facilities can carry the same name, and grouping by name +// silently merges them into one pin. +// • A customer pickup has no facility name at all. Every customer pickup for +// one client used to resolve to the same string (the tenant's name, see +// queries.js) and therefore to ONE map pin, placed at whichever of those +// doorsteps happened to sort first. Ten collections across the city drew +// one marker. +// +// queries.js supplies `pickuppointkey` — one key per facility, one key per +// BOOKING for a customer pickup. The name fallback is kept for order objects +// that never went through that normaliser (the optimiser preview builds its +// own rows), so those behave exactly as they did before. +const pickupPointKey = (o) => + o.pickuppointkey || (pickupName(o) || 'Unknown').toLowerCase().trim(); + +// Does this order require the rider to travel out to a non-facility address to +// collect? Read from the classification queries.js already did; falls back to +// false so an un-normalised row routes the way it always has. +const isCustomerPickup = (o) => o.pickupsource === 'customer'; + +// Text for the flow chip on an order card, or null for no chip. +// +// Deliberately silent on a plain hub run. That is the overwhelming majority of +// every board, and a chip on every card is a chip on none — the point of the +// badge is that a doorstep collection or a point-to-point job stands out from +// the wall of ordinary outbound work around it. +const flowChipLabel = (o) => + o.ordertype && o.ordertype !== FLOW.FORWARD ? o.ordertypelabel || o.ordertype : null; + // Named delivery batches — operator's mental model of the day's waves. // Each entry covers a half-open range [startHour, endHour) measured in // FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported. @@ -403,19 +436,74 @@ const POPUP_TIMELINE = [ { key: 'deliverytime', label: 'Delivered', final: true } ]; -// Build a polyline-ready point list for a sorted trip: -// - drop NaN drops -// - prepend the first valid pickup we can find (so the line starts at the kitchen) -const buildTripPoints = (sorted) => { - const valid = sorted.filter(hasValidDrop); - if (!valid.length) return []; - const pickupSrc = sorted.find(hasValidPickup); - const pts = []; - if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]); - valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)])); - return pts; +// Two points close enough to be the same physical visit. A hub pickup copies +// the facility's coordinates onto every booking, so consecutive stops off one +// kitchen are bit-identical; the tolerance covers a re-pinned gate. +const TRIP_POINT_MERGE_M = 50; + +const isSameTripPoint = (a, b) => { + if (!a || !b) return false; + const R = 6371000; + const toRad = (d) => (d * Math.PI) / 180; + const dLat = toRad(b[0] - a[0]); + const dLon = toRad(b[1] - a[1]); + const h = + Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)) <= TRIP_POINT_MERGE_M; }; +// Build a polyline-ready point list for a sorted trip, as the sequence of +// physical visits the rider actually makes. +// +// This used to be [one pickup, drop, drop, drop, …] — find the FIRST order with +// usable pickup coordinates, put it at the front, then chain every drop. That +// is only true when every stop is collected from the same place, which is true +// for a hub run and false for everything else. A customer pickup collects at a +// doorstep, so its collection point is a real stop on the route; drawing the +// rider's line straight from the kitchen to the delivery address skipped the +// entire first-mile leg and under-reported the distance it covers. +// +// The rule now: a collection point is emitted when the rider actually travels +// to it — always for a customer pickup (each doorstep is its own visit), and +// for a facility only when the rider is not already loaded there (first stop, +// or a switch to a different kitchen, which is the transition the sidebar +// already marks). Consecutive coincident points are merged. +// +// Returns the drop's index in `points` alongside, because callers colour +// per-step segments and previously had to INFER whether a pickup was prepended +// by comparing array lengths (`points.length > drops.length`). That inference +// held only while at most one pickup could ever appear. +const buildTripLegs = (sorted) => { + const valid = (sorted || []).filter(hasValidDrop); + const points = []; + const dropIndices = []; + let loadedAtKey = null; + + const push = (pt) => { + const last = points[points.length - 1]; + if (isSameTripPoint(last, pt)) return points.length - 1; + points.push(pt); + return points.length - 1; + }; + + valid.forEach((o) => { + if (hasValidPickup(o)) { + const key = pickupPointKey(o); + if (isCustomerPickup(o) || key !== loadedAtKey) { + push([toNum(pickupLat(o)), toNum(pickupLon(o))]); + } + // A doorstep collection never leaves the rider "loaded" anywhere they can + // collect again, so it must not suppress the next facility visit. + loadedAtKey = isCustomerPickup(o) ? null : key; + } + dropIndices.push(push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)])); + }); + + return { points, dropIndices, orders: valid }; +}; + +const buildTripPoints = (sorted) => buildTripLegs(sorted).points; + const getTripCacheKey = (riderId, tripKey, points) => { if (!points || points.length === 0) return `${riderId}-${tripKey}`; const pointsSig = points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join('|'); @@ -1789,10 +1877,13 @@ const Dispatch = ({ } }); + // Collection points on the map. Keyed by pickupPointKey, NOT by display + // name: a customer pickup gets its own key per booking, so ten doorstep + // collections draw ten pins instead of one pin at an arbitrary one of them. const kitchenMap = {}; orders.forEach((o) => { const name = pickupName(o) || 'Unknown'; - const key = name.toLowerCase().trim(); + const key = pickupPointKey(o); if (!kitchenMap[key]) { kitchenMap[key] = { id: key, @@ -1800,10 +1891,16 @@ const Dispatch = ({ lat: toNum(pickupLat(o)), lon: toNum(pickupLon(o)), orders: [], - riders: new Set() + riders: new Set(), + // Drives the marker: a facility and a doorstep collection are not the + // same kind of place and must not read as the same kind of pin. + isCustomerPickup: isCustomerPickup(o), + flow: o.ordertype || null }; } else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) { - // Upgrade to first valid pickup coords we see for this kitchen + // Upgrade to first valid pickup coords we see for this kitchen. Safe + // now in a way it was not before: sharing a key means genuinely being + // the same facility, rather than merely sharing a tenant. kitchenMap[key].lat = toNum(pickupLat(o)); kitchenMap[key].lon = toNum(pickupLon(o)); } @@ -2611,7 +2708,7 @@ const Dispatch = ({ Object.entries(trips).forEach(([tNum, tOrders]) => { // Filter orders by focused kitchen if active const filteredTOrders = focusedKitchen - ? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id) + ? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id) : tOrders; if (filteredTOrders.length === 0) return; @@ -2626,7 +2723,8 @@ const Dispatch = ({ const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); // Aerial fallback — NaN-safe build - const aerialPath = buildTripPoints(sorted); + const aerialLegs = buildTripLegs(sorted); + const aerialPath = aerialLegs.points; const isKitchenAerial = viewMode === 'kitchens' || focusedKitchen; const path = roadPath || aerialPath; @@ -2645,15 +2743,20 @@ const Dispatch = ({ const stepSegs = roadPath ? splitPolylineByDrops(roadPath, dropCoords) : (() => { - const hasPickup = aerialPath.length > dropCoords.length; + // Each step's segment runs from wherever the rider was after the + // previous drop to this drop — including any collection point + // inserted in between, which is the whole first-mile leg on a + // customer pickup. Read from the indices buildTripLegs returns; + // this was previously inferred from `path.length > drops.length`, + // which silently assumed at most one pickup existed in the path. const out = []; for (let i = 0; i < dropCoords.length; i++) { - const a = hasPickup ? i : i - 1; - const b = hasPickup ? i + 1 : i; - if (a < 0 || a >= aerialPath.length || b >= aerialPath.length) { + const b = aerialLegs.dropIndices[i]; + const a = i === 0 ? 0 : aerialLegs.dropIndices[i - 1]; + if (b == null || a == null || b >= aerialPath.length || a >= b) { out.push([]); } else { - out.push([aerialPath[a], aerialPath[b]]); + out.push(aerialPath.slice(a, b + 1)); } } return out; @@ -2771,21 +2874,28 @@ const Dispatch = ({ const KITCHEN_ICON_SIZE = 31; const KITCHEN_ICON_FOCUSED_SIZE = 38; - const createKitchenIcon = (name, focused = false) => { + // Collection-point marker. `customerPickup` swaps the amber facility glow for + // a violet one and the initial for an arrow, because a doorstep collection + // and a kitchen are not the same kind of place: a dispatcher scanning the map + // has to be able to tell "load here, it is ours" from "go and collect here" + // without opening the pin. + const createKitchenIcon = (name, focused = false, customerPickup = false) => { const size = focused ? KITCHEN_ICON_FOCUSED_SIZE : KITCHEN_ICON_SIZE; const anchor = Math.round(size / 2); const border = focused ? 3 : 2; const glow = 14; + const rgb = customerPickup ? '139,92,246' : '245,158,11'; + const glyph = customerPickup ? '↑' : (name || 'K').charAt(0).toUpperCase(); return L.divIcon({ className: '', iconSize: [size, size], iconAnchor: [anchor, anchor], popupAnchor: [0, -(anchor + 2)], - html: `

    ${(name || 'K') - .charAt(0) - .toUpperCase()}
    ` + )}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(${rgb},0.8), 0 0 ${glow * 2}px rgba(${rgb},0.4)">${glyph}
    ` }); }; @@ -3239,7 +3349,7 @@ const Dispatch = ({ Object.entries(trips).forEach(([tNum, tOrders]) => { // Filter orders by focused kitchen if active const filteredTOrders = focusedKitchen - ? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id) + ? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id) : tOrders; if (filteredTOrders.length === 0) return; @@ -3293,7 +3403,8 @@ const Dispatch = ({ // line is never invisible while OSRM is in flight. if (!isAllActiveView && !hasRoad && !failed) return; - const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : buildTripPoints(sorted); + const plannedLegs = buildTripLegs(sorted); + const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : plannedLegs.points; if (!finalPoints || finalPoints.length < 2) return; const isKitchenView = viewMode === 'kitchens' || focusedKitchen; @@ -3321,22 +3432,24 @@ const Dispatch = ({ const validDrops = sorted.filter(hasValidDrop); const dropCoords = validDrops.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); // hasRoad: split the OSRM polyline at each drop's nearest index. - // !hasRoad: finalPoints is [pickup?, drop1, drop2, ...] — buildTripPoints - // only prepends the pickup when one's available. Detect that and - // align segment[i] with validDrops[i] either way. + // !hasRoad: align segment[i] with validDrops[i] using the drop indices + // buildTripLegs returns. This was previously inferred from + // `finalPoints.length > dropCoords.length`, which assumed the path + // held at most one pickup — true for a pure hub run, wrong as soon as + // a trip carries doorstep collections, where every extra collection + // point shifted every later segment onto the wrong step's colour. let segments; if (hasRoad) { segments = splitPolylineByDrops(finalPoints, dropCoords); } else { - const hasPickup = finalPoints.length > dropCoords.length; segments = []; for (let i = 0; i < dropCoords.length; i++) { - const idxA = hasPickup ? i : i - 1; - const idxB = hasPickup ? i + 1 : i; - if (idxA < 0 || idxA >= finalPoints.length || idxB >= finalPoints.length) { + const idxB = plannedLegs.dropIndices[i]; + const idxA = i === 0 ? 0 : plannedLegs.dropIndices[i - 1]; + if (idxB == null || idxA == null || idxB >= finalPoints.length || idxA >= idxB) { segments.push([]); } else { - segments.push([finalPoints[idxA], finalPoints[idxB]]); + segments.push(finalPoints.slice(idxA, idxB + 1)); } } } @@ -4369,7 +4482,7 @@ const Dispatch = ({
    {displayOrders.map((o, idx) => { - const kitchenKey = (pickupName(o) || 'Unknown').toLowerCase().trim(); + const kitchenKey = pickupPointKey(o); const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey; prevKitchenKey = kitchenKey; const isStopActive = focusedStop && focusedStop.orderid === o.orderid; @@ -4401,7 +4514,8 @@ const Dispatch = ({ {' '} - Switch to {o.pickupcustomer} + {isCustomerPickup(o) ? 'Collect at' : 'Switch to'}{' '} + {o.pickupcustomer}
    )}
    )} - {o.ordertype && ( + {flowChipLabel(o) && ( - {o.ordertype} + {flowChipLabel(o)} )} @@ -4742,9 +4856,9 @@ const Dispatch = ({ ₹{parseFloat(o.deliverycharge).toFixed(0)} chg )} - {o.ordertype && ( + {flowChipLabel(o) && ( - {o.ordertype} + {flowChipLabel(o)} )} @@ -4936,9 +5050,9 @@ const Dispatch = ({ ₹{parseFloat(o.deliverycharge).toFixed(0)} chg )} - {o.ordertype && ( + {flowChipLabel(o) && ( - {o.ordertype} + {flowChipLabel(o)} )} @@ -5218,7 +5332,7 @@ const Dispatch = ({ setFocusedKitchen(k), diff --git a/src/pages/doormile/hubs/Hubs.jsx b/src/pages/doormile/hubs/Hubs.jsx index 7c7fcde..320b3fb 100644 --- a/src/pages/doormile/hubs/Hubs.jsx +++ b/src/pages/doormile/hubs/Hubs.jsx @@ -6,6 +6,7 @@ import { Stack, StatusBadge, } from '@/components/ds'; import { ListToolbar } from '@/components/doormile/ListToolbar'; +import { validateHubForm } from '@/lib/hubForm'; import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks'; import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat'; @@ -114,23 +115,19 @@ export default function Hubs() { }; const save = () => { - const nextErrors = {}; - if (!form.hubname.trim()) nextErrors.hubname = 'Give the hub a name'; - if (form.applocationid === '' || Number.isNaN(Number(form.applocationid))) { - nextErrors.applocationid = 'Enter the numeric city id'; - } + /* A hub is a BASE to a rider — the six fields the backend hands the rider + app to name, describe and navigate to it. validateHubForm holds why each + one is required; it lives in lib/ so the rule can be tested on its own. */ + const { errors: nextErrors, isValid, values } = validateHubForm(form); setErrors(nextErrors); - if (Object.keys(nextErrors).length) return; + if (!isValid) return; const { hubid, ...rest } = form; const data = { ...rest, - applocationid: Number(rest.applocationid), - /* Coordinates are optional in the form but numeric on the wire — an empty - string here is rejected, whereas 0 is accepted and simply means the hub - has not been pinned on the map yet. */ - latitude: Number(rest.latitude) || 0, - longitude: Number(rest.longitude) || 0, + applocationid: values.applocationid, + latitude: values.latitude, + longitude: values.longitude, }; const mutation = hubid ? updateHub : createHub; @@ -352,19 +349,21 @@ export default function Hubs() { set('contactno')(e.target.value)} placeholder="9876543210" /> - + set('address')(e.target.value)} placeholder="Street, area, landmark" /> - + set('pincode')(e.target.value)} placeholder="641004" /> + {/* Riders navigate to these. A hub saved without them is a base the + app can name but cannot route to. */}
    - + set('latitude')(e.target.value)} placeholder="11.0168" /> - + set('longitude')(e.target.value)} placeholder="76.9558" />
    diff --git a/src/pages/doormile/orders/BookingDestinationsDrawer.jsx b/src/pages/doormile/orders/BookingDestinationsDrawer.jsx new file mode 100644 index 0000000..01ac274 --- /dev/null +++ b/src/pages/doormile/orders/BookingDestinationsDrawer.jsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { MapPin, Package } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { Drawer, EmptyState, LoadingState, StatusBadge } from '@/components/ds'; +import { getBooking } from '@/api/doormile/endpoints'; +import { currency } from '@/lib/doormileFormat'; +import { plural } from '@/lib/bookingDrops'; +import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; + +/** + * The legs of one pickup. + * + * A customer-app pickup is ONE booking carrying N destinations. The list can + * only say how many (`destinationcount`); the array itself is fetched here, on + * open, because the Orders list drains up to twelve pages of a hundred rows and + * would otherwise carry an array it never reads. + * + * Two things this deliberately does NOT do: + * + * - It never reads `booking.consignmentid` or `booking.consignmentstatus`. + * Pickup-complete sets those from the FIRST leg only, for the benefit of + * older single-drop screens, so on a three-drop pickup they are Chennai's + * status wearing the whole booking's name. Per-leg state comes from the + * destination's own `stage` and `consignmentid`, never from the booking's. + * + * - It never invents a tracking number, stage or ETA. Those are minted at + * pickup-complete (cxPickupFanout), so before the rider collects there is + * genuinely nothing to show, and a column of em dashes reads as missing + * data rather than as work that has not happened yet. The columns are + * dropped instead. + */ + +/* The address the customer typed, in the order they typed it. Empty parts are + dropped rather than rendered as gaps: a destination may carry nothing but a + district until the customer completes it. */ +const streetAddress = (destination) => + [destination?.building, destination?.street, destination?.landmark] + .map((part) => String(part || '').trim()) + .filter(Boolean) + .join(', '); + +const DestinationRow = ({ destination, index, showShipmentColumns }) => { + const address = streetAddress(destination); + const expected = parseDoormileTimestamp(destination?.expecteddeliveryat); + const delivered = parseDoormileTimestamp(destination?.deliveredat); + const cod = Number(destination?.codamount) || 0; + const weight = Number(destination?.verifiedweightkg) || 0; + + return ( +
  • + {/* seq is the customer-facing position and the {index} in + PATCH /customer/bookings/{ref}/destinations/{index}. Shown one-based + as the customer counts them, and never re-sorted. */} + + {String(index + 1).padStart(2, '0')} + + +
    +
    +

    + {destination?.districtname || 'Destination'} +

    + {destination?.statename && ( + {destination.statename} + )} + + {`· ${plural(Number(destination?.packagecount) || 0, 'package')}`} + + {showShipmentColumns && destination?.stage && ( + + )} +
    + + {address && ( +

    + {address} + {destination?.pincode ? ` · ${destination.pincode}` : ''} +

    + )} + + {(destination?.recipientname || destination?.recipientphone) && ( +

    + {destination.recipientname || 'Recipient'} + {destination.recipientphone ? ` · ${destination.recipientphone}` : ''} +

    + )} + + {destination?.instructions && ( +

    {destination.instructions}

    + )} + +
    + {showShipmentColumns && destination?.trackingno && ( + {destination.trackingno} + )} + {cod > 0 && {`COD ${currency(cod)}`}} + {weight > 0 && {`${weight} kg verified`}} + {delivered.isValid() ? ( + {`Delivered ${delivered.format('DD MMM, hh:mm A')}`} + ) : ( + expected.isValid() && {`Expected ${expected.format('DD MMM, hh:mm A')}`} + )} +
    +
    +
  • + ); +}; + +export function BookingDestinationsDrawer({ bookingId, bookingNo, open, onOpenChange }) { + /* Fetched on open, never on list render. `enabled` is what keeps this from + firing for every row the operator scrolls past. */ + const { data: booking, isLoading, isError } = useQuery({ + queryKey: ['doormile', 'booking', bookingId], + queryFn: () => getBooking(bookingId), + enabled: Boolean(open && bookingId), + }); + + const destinations = Array.isArray(booking?.destinations) ? booking.destinations : []; + + /* Tracking numbers and stages are minted together at pickup-complete, so + "any leg has one" is the same question as "has this pickup been collected". + Asked across all legs rather than per row, so the columns appear or vanish + as a set instead of ragged. */ + const showShipmentColumns = destinations.some( + (destination) => destination?.trackingno || destination?.stage + ); + + const totalPackages = destinations.reduce( + (sum, destination) => sum + (Number(destination?.packagecount) || 0), + 0 + ); + + return ( + +
    + {isLoading && } + + {isError && !isLoading && ( + + )} + + {!isLoading && !isError && destinations.length === 0 && ( + /* Two different situations reach here and the copy has to fit both: a + console-created booking genuinely has no destination rows, and a + console running ahead of the backend gets no `destinations` key at + all. Neither is an error worth alarming an operator about. */ + + )} + + {!isLoading && destinations.length > 0 && ( + <> + {!showShipmentColumns && ( +

    + Tracking numbers appear once the rider completes pickup. Each drop becomes its own + shipment then. +

    + )} +
      + {destinations.map((destination, index) => ( + + ))} +
    + + )} +
    +
    + ); +} + +export default BookingDestinationsDrawer; diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx index 4782826..6d65a5a 100644 --- a/src/pages/doormile/orders/CreateOrder.jsx +++ b/src/pages/doormile/orders/CreateOrder.jsx @@ -20,7 +20,8 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import AddressAutocomplete, { geocodeAddress } from '@/components/doormile/AddressAutocomplete'; +import AddressAutocomplete from '@/components/doormile/AddressAutocomplete'; +import MapPinPickerModal from '@/components/doormile/MapPinPickerModal'; import { HubLocationSelect } from '@/components/doormile/HubLocationSelect'; import { createExpressBooking, @@ -30,6 +31,14 @@ import { getTenantLocations } from '@/api/doormile/endpoints'; import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance'; +import { useHubs } from '@/lib/doormileHooks'; +import { + PICKUP_SOURCE, + buildAnchors, + buildFlowFields, + flowForDraft, + flowLabel +} from '@/lib/orderFlow'; import { OpenToast } from '@/components/third-party/OpenToast'; const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast']; @@ -95,6 +104,13 @@ export default function CreateOrder() { const [pickCust, setPickCust] = useState(EMPTY_PARTY); const [dropCust, setDropCust] = useState(EMPTY_PARTY); + // Where the parcel is collected from. 'hub' copies the selected business + // location onto the pickup block, which is what this form has always done. + // 'customer' leaves the pickup fields to the operator, because the collection + // point is a doorstep that no saved location describes. + const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB); + const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER; + const [pickupDate, setPickupDate] = useState(dayjs().format('YYYY-MM-DD')); const [selectedSlotTime, setSelectedSlotTime] = useState('09:00'); const [serviceOption, setServiceOption] = useState('Normal'); @@ -109,6 +125,9 @@ export default function CreateOrder() { const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false); const [searchCustQuery, setSearchCustQuery] = useState(''); + // Map pin picker modal target ('pickup' | 'drop' | null) + const [mapPickerTarget, setMapPickerTarget] = useState(null); + // Queries const { data: staffTenants = [], isLoading: isTenantsLoading } = useQuery({ queryKey: ['adminTenants'], @@ -132,6 +151,17 @@ export default function CreateOrder() { queryFn: getAdminPricing }); + // Facilities, so a DROP that lands on one is recognised as a hub rather than + // treated as a customer's address. Without this, a rider bringing a parcel + // back to the hub would be booked as point-to-point and routed as if the hub + // were somebody's house. + const { data: hubList = [] } = useHubs(); + + const flowAnchors = useMemo( + () => buildAnchors({ hubs: hubList, tenantLocations: tenantLocations }), + [hubList, tenantLocations] + ); + const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({ queryKey: ['adminCustomers'], queryFn: getAdminCustomers, @@ -148,24 +178,30 @@ export default function CreateOrder() { const sortedLocations = useMemo(() => sortLocations(tenantLocations), [tenantLocations]); - // Auto-select single location + // Auto-select single location. + // + // The location is still selected under a customer pickup — it is the client's + // servicing origin and the order belongs to it — but its address is NOT + // copied over the pickup block, which would silently overwrite the doorstep + // the operator just typed with the kitchen's address. useEffect(() => { if (sortedLocations.length === 1) { const loc = sortedLocations[0]; setSelectedLocationId(String(loc.tenantlocationid || loc.locationid)); setSelectedLocation(loc); - applyLocationToPickup(loc); + if (!isCustomerPickup) applyLocationToPickup(loc); } else if (sortedLocations.length === 0) { setSelectedLocationId(''); setSelectedLocation(null); } - }, [sortedLocations]); + }, [sortedLocations, isCustomerPickup]); const applyLocationToPickup = (loc) => { if (!loc) return; const lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat; const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong; + // @ts-ignore setPickCust((prev) => ({ ...prev, firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point', @@ -190,9 +226,42 @@ export default function CreateOrder() { } const loc = sortedLocations.find((l) => String(l.tenantlocationid || l.locationid) === locId); setSelectedLocation(loc || null); - if (loc) applyLocationToPickup(loc); + if (loc && !isCustomerPickup) applyLocationToPickup(loc); }; + // Switching collection mode. + // + // Going to 'customer' CLEARS the pickup block rather than leaving the hub's + // address sitting in it. A half-cleared form is the dangerous state here: the + // operator retypes the address, misses the coordinates, and books a doorstep + // collection that is still pinned on the kitchen — which routes a rider to + // the wrong end of the city and prices the job off the wrong leg. + const handleChangePickupMode = (mode) => { + if (mode === pickupMode) return; + setPickupMode(mode); + if (mode === PICKUP_SOURCE.CUSTOMER) { + setPickCust(EMPTY_PARTY); + } else if (selectedLocation) { + applyLocationToPickup(selectedLocation); + } + }; + + // What this order will be booked as. The pickup end is whatever the operator + // chose; the drop end is measured, because a free-typed delivery address that + // lands on a facility is a return to the hub, not a delivery to a customer. + const draftFlow = useMemo( + () => + flowForDraft({ + pickupSource: pickupMode, + pickupLat: pickCust.latitude, + pickupLng: pickCust.longitude, + dropLat: dropCust.latitude, + dropLng: dropCust.longitude, + anchors: flowAnchors + }), + [pickupMode, pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude, flowAnchors] + ); + // Tenant Pricing const clientRate = useMemo(() => { const list = Array.isArray(pricingList) ? pricingList : []; @@ -232,6 +301,45 @@ export default function CreateOrder() { }; }, [pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude]); + // First-mile ("deadhead") leg. + // + // On a hub run the rider starts where the parcel is, so the only distance + // that exists is pickup → drop. A customer pickup adds a leg BEFORE that: + // the rider travels from the servicing location out to the customer's door, + // carrying nothing. It is real distance, real time and real fuel, and until + // now nothing in this console measured it. + // + // Measured and shown, NOT billed. What to charge for an empty leg is a + // tariff decision, and quietly folding it into every customer-pickup order's + // price would change what clients are invoiced without anyone choosing that. + // `totalCharge` below is deliberately left on the loaded leg alone. + const [deadhead, setDeadhead] = useState({ distance: 0, resolved: false }); + + useEffect(() => { + const oLat = Number(selectedLocation?.latitude); + const oLng = Number(selectedLocation?.longitude); + const pLat = Number(pickCust.latitude); + const pLng = Number(pickCust.longitude); + + if (!isCustomerPickup || !oLat || !oLng || !pLat || !pLng) { + setDeadhead({ distance: 0, resolved: false }); + return; + } + + let active = true; + calculateDrivingDistance({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng }) + .then((distance) => { + if (active) setDeadhead({ distance: Math.max(0, distance || 0), resolved: true }); + }) + .catch(() => { + if (active) setDeadhead({ distance: 0, resolved: false }); + }); + + return () => { + active = false; + }; + }, [isCustomerPickup, selectedLocation?.latitude, selectedLocation?.longitude, pickCust.latitude, pickCust.longitude]); + // Total Charge calculation const totalCharge = useMemo(() => { const base = Number(clientRate?.baseprice) || 40; @@ -248,6 +356,7 @@ export default function CreateOrder() { // Place selection helpers const handlePlaceSelect = (partyType, place) => { + if (!place) return; const parsed = { suburb: '', city: '', postcode: '' }; (place.address_components || []).forEach((c) => { (c.types || []).forEach((type) => { @@ -257,19 +366,22 @@ export default function CreateOrder() { }); }); - const lat = place.geometry?.location?.lat ? place.geometry.location.lat() : ''; - const lng = place.geometry?.location?.lng ? place.geometry.location.lng() : ''; - const address = place.formatted_address || ''; + const lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : ''); + const lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : ''); + const address = place.formatted_address || place.name || ''; + const suburb = place.suburb || parsed.suburb; + const city = place.city || parsed.city; + const postcode = place.postcode || parsed.postcode; const setter = partyType === 'pickup' ? setPickCust : setDropCust; setter((prev) => ({ ...prev, - address, - suburb: parsed.suburb || prev.suburb, - city: parsed.city || prev.city, - postcode: parsed.postcode || prev.postcode, - latitude: lat, - longitude: lng + address: address || prev.address, + suburb: suburb || prev.suburb, + city: city || prev.city, + postcode: postcode || prev.postcode, + latitude: lat !== '' ? lat : prev.latitude, + longitude: lng !== '' ? lng : prev.longitude })); }; @@ -320,6 +432,21 @@ export default function CreateOrder() { OpenToast('Enter pickup address', 'warning', 2500); return; } + // Pickup coordinates were never checked here. That was survivable only + // while the pickup was always a saved location, which supplies its own + // coordinates — a hand-typed collection address that fails to geocode + // submits 0,0, and an order pinned at 0,0 is unroutable, unpriceable and + // invisible to every zone filter. + if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) { + OpenToast( + isCustomerPickup + ? 'Pick the collection address from the suggestions, or drop a map pin, so it has coordinates' + : 'The selected hub has no coordinates — set them on the Hubs page before booking from it', + 'warning', + 4000 + ); + return; + } if (!dropCust.firstname.trim()) { OpenToast('Enter delivery contact name', 'warning', 2500); return; @@ -369,7 +496,23 @@ export default function CreateOrder() { service_option: SERVICE_OPTIONS.includes(serviceOption) ? serviceOption : 'Normal', finalprice, notes: notes || '', - parcels + parcels, + // Which flow this is, and which hub owns it. Sent even where the backend + // may not store them yet: an unknown key is ignored server-side, and the + // console classifies from coordinates in the meantime, so nothing depends + // on these landing. The day they are persisted, every order created from + // here carries its own type instead of being re-derived on each load. + ...buildFlowFields({ + flow: draftFlow.flow, + servicingHubId: + draftFlow.originAnchor?.hubid ?? draftFlow.destAnchor?.hubid ?? selectedLocation?.hubid ?? null, + tenantLocationId: selectedLocationId, + // The collection point itself, so the order records what KIND of place + // it leaves from — a base, a client's site, or a customer's door. The + // backend stores it and the rider app titles the stop with it; without + // it every logistics pickup reads as the rider's own base name. + pickupAnchor: draftFlow.originAnchor + }) }; setIsSubmitting(true); @@ -523,10 +666,77 @@ export default function CreateOrder() {
    - {selectedLocation && ( - - {selectedLocation.locationname || 'Hub Selected'} + {/* The badge names the COLLECTION POINT, so it has to stop + naming the hub the moment the hub is not where we collect. */} + {isCustomerPickup ? ( + + {flowLabel(draftFlow.flow)} + ) : ( + selectedLocation && ( + + {selectedLocation.locationname || 'Hub Selected'} + + ) + )} + + + {/* Collect from the client's own location, or from a customer's + door. This is the switch the whole order flow turns on: it + decides whether the pickup block is filled from the saved + location or typed, and it is what the board later reads to + draw a doorstep collection as its own stop on the route. */} +
    +

    Collect from

    +
    + {[ + { + mode: PICKUP_SOURCE.HUB, + icon: Building2, + title: 'Business Hub', + hint: 'Client kitchen, depot or branch' + }, + { + mode: PICKUP_SOURCE.CUSTOMER, + icon: Users, + title: 'Customer Pickup', + hint: "Collect at a customer's own address" + } + ].map(({ mode, icon: Icon, title, hint }) => { + const active = pickupMode === mode; + return ( + + ); + })} +
    + + {isCustomerPickup && ( +

    + The rider travels out to this address to collect. Pick it from the suggestions or drop a + map pin so it carries coordinates — the first-mile leg is priced and routed from them. +

    )}
    @@ -566,10 +776,11 @@ export default function CreateOrder() { id="pickup-address" label="Pickup Address" required - placeholder="Search address or type location" + placeholder="Search address, building or locality" value={pickCust.address} onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))} onPlaceSelected={(place) => handlePlaceSelect('pickup', place)} + onOpenMapPin={() => setMapPickerTarget('pickup')} /> @@ -676,11 +887,12 @@ export default function CreateOrder() { id="drop-address" label="Delivery Address" required - placeholder="Search delivery address or locality" + placeholder="Search delivery address, society or landmark" value={dropCust.address} bias={pickCust.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined} onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))} onPlaceSelected={(place) => handlePlaceSelect('drop', place)} + onOpenMapPin={() => setMapPickerTarget('drop')} /> @@ -970,11 +1182,35 @@ export default function CreateOrder() { {/* Journey Legs */}
    + {/* The empty run out to the customer's door. Drawn as its own + leg because that is what it is — the rider covers it before + the parcel exists on the trip. */} + {isCustomerPickup && selectedLocation && ( + <> +
    +
    +
    +
    + {selectedLocation.locationname || 'Servicing Hub'} +
    +
    + Rider starts here · travels empty +
    +
    +
    +
    + + )} +
    -
    +
    - {pickCust.firstname || selectedLocation?.locationname || 'Pickup Point'} + {pickCust.firstname || (isCustomerPickup ? 'Collection Point' : selectedLocation?.locationname) || 'Pickup Point'}
    {pickCust.address || 'Address not selected'}
    @@ -1010,6 +1246,22 @@ export default function CreateOrder() {
    + {/* Shown only when it exists, and labelled as unbilled so nobody + reconciles the invoice against the wrong number. */} + {isCustomerPickup && deadhead.resolved && ( +
    +
    +
    + First-mile leg +
    +
    + Hub → collection point · not billed +
    +
    +
    {deadhead.distance} km
    +
    + )} + {/* Rate Card Info */}
    @@ -1155,6 +1407,22 @@ export default function CreateOrder() {
    )} + + {/* Map Pin Picker Modal for Pinpoint Accuracy */} + setMapPickerTarget(null)} + title={mapPickerTarget === 'pickup' ? 'Pin Exact Pickup Location' : 'Pin Exact Delivery Location'} + initialAddress={mapPickerTarget === 'pickup' ? pickCust.address : dropCust.address} + initialLat={mapPickerTarget === 'pickup' ? pickCust.latitude : dropCust.latitude} + initialLng={mapPickerTarget === 'pickup' ? pickCust.longitude : dropCust.longitude} + onConfirm={(place) => { + if (mapPickerTarget) { + handlePlaceSelect(mapPickerTarget, place); + } + setMapPickerTarget(null); + }} + />
    ); } diff --git a/src/pages/doormile/orders/MultipleOrders.jsx b/src/pages/doormile/orders/MultipleOrders.jsx index 06d9140..a906785 100644 --- a/src/pages/doormile/orders/MultipleOrders.jsx +++ b/src/pages/doormile/orders/MultipleOrders.jsx @@ -38,11 +38,13 @@ import { requiredSheetColumns } from '@/lib/bulkOrderColumns'; import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance'; +import { useHubs } from '@/lib/doormileHooks'; +import { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow'; +import { buildBulkBookingPayloads, rowPickupOf } from '@/lib/bulkOrderPayload'; import { OpenToast } from '@/components/third-party/OpenToast'; -// Nominatim serves this console's geocoding for free and asks for no more than -// one request a second in return. See handleProcessUsersToDrops. -const GEOCODE_INTERVAL_MS = 1100; +// Multi-provider geocoding service allows sub-300ms lookups +const GEOCODE_INTERVAL_MS = 250; const buildDefaultPickupSlots = () => { const arr = []; @@ -85,6 +87,21 @@ export default function MultipleOrders() { const [pricePerKm, setPricePerKm] = useState(0); const [minKm, setMinKm] = useState(0); + // Where every row in this run is collected from. + // + // 'hub' — one shared collection point, the selected business location. + // This is what the page has always done, and stays the default. + // 'customer' — each ROW carries its own collection point, read from the + // sheet's Sender Name / Sender Phone / Sender Address columns. + // + // Those sender columns are not new. SHEET_HEADER_MAP has always mapped them + // (to `locationname` / `locationcontact` / `locationaddress`) and the page has + // always parsed them and then thrown them away, overwriting every row's + // origin with the hub. A tenant sending a file of doorstep collections was + // therefore booking every one of them against the kitchen. + const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB); + const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER; + // Upload & selection state const [uploadType, setUploadType] = useState(null); // 0: file, 1: selection const [fileName, setFileName] = useState(''); @@ -120,6 +137,13 @@ export default function MultipleOrders() { queryFn: getAdminPricing }); + const { data: hubList = [] } = useHubs(); + + const flowAnchors = useMemo( + () => buildAnchors({ hubs: hubList, tenantLocations }), + [hubList, tenantLocations] + ); + const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({ queryKey: ['adminCustomers'], queryFn: getAdminCustomers, @@ -169,11 +193,24 @@ export default function MultipleOrders() { }; // Distance calculation helper + // The collection point for ONE row. + // + // `__pickup` is attached per row during processing when the run collects from + // customers; it travels on the row rather than on the page so a file can mix + // origins without one row silently re-addressing another. Falls back to the + // shared business location, which is every row's answer on a hub run. + const rowPickup = (customer) => rowPickupOf(customer, pickCust); + const calculateDistance = async (customer) => { if (!customer || typeof customer !== 'object') throw new Error('Invalid customer data.'); - if (!pickCust || typeof pickCust !== 'object') throw new Error('Pickup location missing.'); + const origin = rowPickup(customer); + if (!origin || typeof origin !== 'object') throw new Error('Pickup location missing.'); - const roundedDistance = await calculateDrivingDistance(pickCust, customer); + // Measured from THIS row's collection point. Measuring every row from the + // hub while collecting at a doorstep prices the wrong leg entirely — for a + // collection near the recipient it bills a cross-town run that no rider + // makes, and for one far from both it under-bills. + const roundedDistance = await calculateDrivingDistance(origin, customer); const totalcharge = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm); return { roundedDistance, totalcharge }; }; @@ -275,6 +312,12 @@ export default function MultipleOrders() { if (!users.length) return; setIsProcessingRows(true); const newDrops = []; + // One lookup per DISTINCT sender address, not per row. A real tenant file + // is three home kitchens across thirteen drops — geocoding the same kitchen + // six times costs six rate-limited round trips for one answer, and risks + // the six rows landing on marginally different coordinates, which would + // then draw six collection pins where the operator expects one. + const senderPlaceCache = new Map(); // Set once the first address lookup has gone out, so the pause below is // paid between lookups rather than before the first one. let geocoded = false; @@ -296,14 +339,56 @@ export default function MultipleOrders() { [customer.address, customer.city, customer.postcode].filter(Boolean).join(', '), { bias: pickCust?.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined } ); - if (place?.geometry?.location) { - lat = place.geometry.location.lat(); - lng = place.geometry.location.lng(); + if (place) { + lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : null); + lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : null); + } + } + + // The row's own collection point, from the sheet's sender columns. + // Geocoded the same way the delivery address is — a pickup without + // coordinates is unroutable, and a row that cannot produce them keeps + // the shared hub rather than being booked at 0,0. + let rowOwnPickup = null; + if (isCustomerPickup) { + const senderAddress = String(customer.locationaddress || customer.senderaddress || '').trim(); + if (senderAddress) { + if (!senderPlaceCache.has(senderAddress)) { + if (geocoded) await new Promise((resolve) => setTimeout(resolve, GEOCODE_INTERVAL_MS)); + geocoded = true; + senderPlaceCache.set( + senderAddress, + await geocodeAddress(senderAddress, { + bias: pickCust?.latitude + ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } + : undefined + }) + ); + } + const senderPlace = senderPlaceCache.get(senderAddress); + const sLat = senderPlace?.latitude ?? senderPlace?.geometry?.location?.lat?.(); + const sLng = senderPlace?.longitude ?? senderPlace?.geometry?.location?.lng?.(); + if (sLat && sLng) { + rowOwnPickup = { + locationname: customer.locationname || 'Customer Pickup', + contactno: String(customer.locationcontact || '').replace(/\D/g, '').slice(0, 10), + address: senderPlace.formatted_address || senderAddress, + city: senderPlace.city || '', + pincode: senderPlace.postcode || '', + latitude: sLat, + longitude: sLng + }; + } } } const enrichedCustomer = { ...customer, + __pickup: rowOwnPickup, + // Surfaced on the row so an operator reviewing the preview can see + // which rows fell back to the hub because their sender address would + // not geocode, instead of discovering it after 200 orders are live. + __pickupFellBack: isCustomerPickup && !rowOwnPickup, latitude: lat || null, longitude: lng || null, quantity: Number(customer.quantity) || 1, @@ -323,6 +408,19 @@ export default function MultipleOrders() { setDropCust(newDrops); setIsProcessingRows(false); + + const fellBack = newDrops.filter((d) => d.__pickupFellBack).length; + if (fellBack > 0) { + // Named, not silent. These rows will be collected from the hub, which is + // a different journey from the one the sheet asked for. + OpenToast( + `${fellBack} of ${newDrops.length} rows had no usable Sender Address — those will be collected from ${ + pickCust?.locationname || 'the selected hub' + }`, + 'warning', + 6000 + ); + } OpenToast(`Processed ${newDrops.length} delivery drops`, 'success', 2500); }; @@ -448,32 +546,20 @@ export default function MultipleOrders() { 'YYYY-MM-DD HH:mm:ss' ); - const bookings = dropCust.map((customer) => ({ - tenantid: Number(selectedTenantId), - pickupaddress: pickCust.address || '', - pickuppincode: pickCust.pincode || pickCust.postcode || '', - pickupcity: pickCust.city || '', - pickuplatitude: Number(pickCust.latitude) || 0, - pickuplongitude: Number(pickCust.longitude) || 0, - customer_phone: customer.contactno?.toString() || '', - customer_name: customer.firstname || '', - deliveryaddress: customer.address || '', - deliverypincode: customer.postcode?.toString() || '', - deliverycity: customer.city || '', - deliverylatitude: Number(customer.latitude) || 0, - deliverylongitude: Number(customer.longitude) || 0, + // The transform lives in lib/bulkOrderPayload so a test can drive the real + // thing against a real tenant file, rather than a copy of it that agrees + // today. Per row, never collapsed onto one shared origin: a run can mix a + // doorstep collection with a hub pickup that fell back, and writing the hub + // across all of them would silently re-address half the file. + const bookings = buildBulkBookingPayloads({ + rows: dropCust, + sharedPickup: pickCust, + tenantId: selectedTenantId, + tenantLocationId: selectedLocationId, deliverytime, - service_option: 'Normal', - finalprice: Number(customer.totalcharge) || 0, - notes: otherInstructions || '', - parcels: [ - { - itemcategory: 'General', - itemdescription: 'Order', - declaredvalue: Number(customer.totalcharge) || 0 - } - ] - })); + notes: otherInstructions, + anchors: flowAnchors + }); setIsSubmitting(true); try { @@ -625,6 +711,56 @@ export default function MultipleOrders() { />
    + {/* Where this run collects from. The hub stays selected either + way — it is the servicing location the orders belong to, and + the fallback for any row whose sender address will not + geocode — but under 'customer' it stops being the address + written onto every booking. */} +
    +

    Collect from

    +
    + {[ + { mode: PICKUP_SOURCE.HUB, title: 'Business Hub', hint: 'One shared origin' }, + { mode: PICKUP_SOURCE.CUSTOMER, title: 'Customer Pickup', hint: "Each row's sender address" } + ].map(({ mode, title, hint }) => { + const active = pickupMode === mode; + return ( + + ); + })} +
    + + {isCustomerPickup && ( +

    + Each row is collected from its Sender Address column. Rows without a usable + one fall back to the hub above, and you will be told how many. +

    + )} +
    +
    @@ -661,6 +797,13 @@ export default function MultipleOrders() {
    {pickCust.locationname || 'Pickup Hub'}
    {pickCust.address || '—'}
    + {/* This tile reads as "everything is collected here", so it + has to say when that is no longer true. */} + {isCustomerPickup && ( +
    + Servicing hub only — rows are collected from their own sender addresses +
    + )}
    ) : ( @@ -887,6 +1030,14 @@ export default function MultipleOrders() { # Customer + {/* Only in customer-pickup mode, and deliberately BEFORE the + delivery address: a geocoded sender line is the one thing + on this screen the operator has not seen with their own + eyes. A coarse "Edayarpalayam, Coimbatore" resolves to a + landmark in that locality, not the kitchen, and a rider + sent there arrives at the wrong door. Better caught here + than after thirteen orders are live. */} + {isCustomerPickup && Collect from} Address Qty Cash (₹) @@ -905,6 +1056,30 @@ export default function MultipleOrders() {
    {row.firstname || '—'}
    {row.contactno || '—'}
    + {isCustomerPickup && ( + + {row.__pickup ? ( + <> +
    + {row.__pickup.locationname || 'Customer Pickup'} +
    +
    + {row.__pickup.address} +
    + + ) : ( +
    + Fell back to hub +
    + )} + + )} {needsAddress ? ( statusesInGroup(tab), [tab]); + /* Which end of each booking is a facility. This page reads RAW bookings + rather than the deliveries feed, so it has to classify for itself — but + against the same shared anchor list, so a row cannot read "customer + pickup" here and "hub run" on Dispatch. */ + const { data: flowAnchors = [] } = useFlowAnchors((bookings || []).map((b) => b?.tenantid)); + const rows = useMemo( () => (zoneBookings || []) @@ -315,17 +327,37 @@ export default function Orders() { customer?.firstname || customer?.name || (row.appcustomerid ? `Customer #${row.appcustomerid}` : 'Drop'); + /* "Pickup" alone reads as "collected from our hub", which is what it + always meant. Once it can also mean a doorstep, the row has to say + which — otherwise two very different journeys look identical in the + list. Silent on an ordinary hub run, so the label only appears + where it carries information. */ + const flow = classifyBooking(row, flowAnchors); + const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer'; return (

    - Pickup + + {isCustomerPickup ? 'Customer pickup' : 'Pickup'} + {row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}

    -

    - {dropName} - {row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''} - {row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''} -

    + {/* Collapsed, a multi-drop row reads as ONE pickup request that + created several shipments — never as one of them. The flat + `deliveryaddress` it would otherwise show is destination 0 + mirrored by CreateCxBooking, so on a three-drop pickup it is + Chennai standing in for all three. Open the row for the legs. */} + {isMultiDrop(row) ? ( +

    + {dropSummary(row)} +

    + ) : ( +

    + {dropName} + {row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''} + {row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''} +

    + )}
    ); }, @@ -361,6 +393,21 @@ export default function Orders() { return ; } if (rawStatus === 'converted_to_consignment') { + /* THE ONE THAT WOULD SILENTLY LIE. + `pickupbookings.consignmentid` names the FIRST leg only — set + that way at pickup-complete so single-drop screens keep working + — and `consignmentstatus` is joined from it. On a three-drop + pickup that is Chennai's status wearing the whole booking's + name, and rendering it here would confidently report "Delivered" + while two parcels are still in transit. + + A multi-drop row therefore shows the PICKUP's own state, which + is genuinely finished once the rider has collected. Per-leg + status lives in the drawer, read from each destination's own + stage. */ + if (isMultiDrop(row)) { + return ; + } const cStatus = row.consignmentstatus ?? row.consignment_status; return ; } @@ -476,6 +523,18 @@ export default function Orders() { {!isAllZones && ( · Zone: {selectedZone.hubname} )} + {/* Search runs over the flat booking columns, and `deliveryaddress` + is destination 0 mirrored by CreateCxBooking. Searching + "Ernakulam" on a three-drop pickup whose first drop is Chennai + therefore finds nothing. The list has no destination array to + index — it carries only the counts, deliberately — so the honest + fix is to say which drop is searched rather than to fail + silently. */} + {debouncedSearch && ( + + {' '}· search covers the order number, pickup, notes and the first drop only + + )} {/* A capped scan means an order really can be missing from this tab. Say so — a silently short list is what sent operators hunting for an order they had just created. */} @@ -493,6 +552,12 @@ export default function Orders() { columns={columns} rows={rows} getRowId={(row) => row.bookingid} + /* Opens the drops for ANY booking, not only multi-drop ones: the + per-destination recipient, instructions and COD exist nowhere else + in the console, and a row that is clickable only sometimes is worse + than one that always is. The checkbox cell stops propagation, so + this does not fight selection. */ + onRowClick={(row) => setDropsRow(row)} loading={isLoading} refreshing={isFetching && !isLoading} selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined} @@ -630,6 +695,15 @@ export default function Orders() { }) } /> + + { + if (!next) setDropsRow(null); + }} + bookingId={dropsRow?.bookingid} + bookingNo={dropsRow?.bookingno || (dropsRow ? `#${dropsRow.bookingid}` : undefined)} + /> ); } diff --git a/tests/fixtures/doormile-logistics-test.xlsx b/tests/fixtures/doormile-logistics-test.xlsx new file mode 100644 index 0000000..39e0093 Binary files /dev/null and b/tests/fixtures/doormile-logistics-test.xlsx differ diff --git a/tests/fixtures/jayanthi-kitchen-route.xls b/tests/fixtures/jayanthi-kitchen-route.xls new file mode 100644 index 0000000..a00ab7f Binary files /dev/null and b/tests/fixtures/jayanthi-kitchen-route.xls differ diff --git a/tests/integration/bulkUpload.test.js b/tests/integration/bulkUpload.test.js new file mode 100644 index 0000000..13e11bd --- /dev/null +++ b/tests/integration/bulkUpload.test.js @@ -0,0 +1,459 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as XLSX from 'xlsx'; + +import { + SHEET_HEADER_MAP, + cleanReceiverName, + normalizeHeader, + requiredSheetColumns +} from '@/lib/bulkOrderColumns'; +import { buildBulkBookingPayloads, buildParcels } from '@/lib/bulkOrderPayload'; +import { FLOW, buildAnchors, classifyBooking } from '@/lib/orderFlow'; +import { calculateTotalCharge } from '@/lib/distance'; + +/** + * Bulk upload, end to end, against a REAL tenant file. + * + * `tests/fixtures/jayanthi-kitchen-route.xls` is an actual export a tenant + * uploaded: 13 lunch deliveries around Coimbatore, leaving from THREE different + * home kitchens — Jayanthi (6 drops), Latha (3), Manjusha (4). + * + * That file is the whole reason this suite exists. The page's bulk path was + * built on the assumption that one upload has one origin: it parsed the sheet's + * Sender columns and then overwrote every row's pickup with the single business + * location the operator had selected. Uploading this file therefore booked all + * 13 orders against one kitchen and silently erased the other two — the rider + * would be sent to Edayarpalayam for parcels sitting in Kavundampalayam. + * + * These tests drive the REAL modules the page imports — the real header map, + * the real payload builder, the real flow classifier. Nothing is reimplemented + * here, so a passing test is a statement about what the page does rather than + * about a copy of it that happens to agree today. + * + * The one thing deliberately NOT exercised is network geocoding. The fixture + * carries Receiver Latitude/Longitude for every row, so drops need no lookup; + * sender coordinates are injected from a stub table below, standing in for what + * the geocoder returns. That keeps the suite hermetic and deterministic while + * still covering every transformation that decides what goes on the wire. + */ + +const FIXTURE = path.join(__dirname, '..', 'fixtures', 'jayanthi-kitchen-route.xls'); + +// The tenant's selected business location — one origin, as the page has always +// assumed. Coordinates are Jayanthi's kitchen. +const BUSINESS_LOCATION = { + tenantlocationid: 31, + tenantid: 4, + hubid: 7, + locationname: 'Jayanthi Kitchen', + address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025', + city: 'Coimbatore', + pincode: '641025', + latitude: 11.0168, + longitude: 76.9558 +}; + +const DOORMILE_HUB = { + hubid: 7, + hubname: 'Coimbatore Gandhipuram Hub', + latitude: 11.0168, + longitude: 76.9558 +}; + +/* Stands in for the geocoder. Keyed by the sheet's exact Sender Address text — + the three kitchens the file actually contains. */ +const SENDER_COORDS = { + 'Edayarpalayam, Coimbatore, Tamil Nadu 641025': { lat: 11.0168, lng: 76.9558 }, + 'Kavundampalayam, 641030': { lat: 11.0447, lng: 76.9382 }, + 'Ramakrishnamill, Ganapathy, Coimbatore - 641006': { lat: 11.0296, lng: 76.9891 } +}; + +const RATE = { basePrice: 40, pricePerKm: 8, minKm: 5 }; +const DELIVERY_TIME = '2026-09-01 09:00:00'; + +// ---- the page's own parse, reproduced from MultipleOrders.processData ------- +// The mapping table and normalizer are imported, not copied; only the loop that +// walks them is restated, because it lives inside a component. +const parseFixture = () => { + const wb = XLSX.read(fs.readFileSync(FIXTURE), { type: 'buffer' }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const raw = XLSX.utils.sheet_to_json(sheet, { defval: '' }); + + const normalizedMap = {}; + for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key]; + + const rows = raw.map((row, idx) => { + const out = { _rowkey: `csv-${idx}`, quantity: 1, collectionamt: 0 }; + for (const key in row) { + const clean = normalizeHeader(key); + const mapped = normalizedMap[clean] || clean; + out[mapped] = mapped === 'firstname' ? cleanReceiverName(row[key]) : row[key]; + } + return out; + }); + + return { raw, rows, headers: Object.keys(raw[0]).map(normalizeHeader) }; +}; + +/** Straight-line km, standing in for the OSRM call the page makes. Only used to + * give each row a plausible price so the payload has a real number in it. */ +const km = (a, b) => { + const toRad = (d) => (d * Math.PI) / 180; + const dLat = toRad(b.lat - a.lat); + const dLon = toRad(b.lng - a.lng); + const h = + Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; + return Math.round(6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h))); +}; + +/** Rows as the page holds them after processing: coordinates resolved, price + * computed, and `__pickup` attached when the run collects from customers. */ +const processRows = (rows, { customerPickup }) => + rows.map((row) => { + const senderAddress = String(row.locationaddress || '').trim(); + const coords = SENDER_COORDS[senderAddress]; + const pickup = + customerPickup && coords + ? { + locationname: row.locationname || 'Customer Pickup', + contactno: String(row.locationcontact || '').replace(/\D/g, '').slice(0, 10), + address: senderAddress, + city: '', + pincode: '', + latitude: coords.lat, + longitude: coords.lng + } + : null; + + const origin = pickup || BUSINESS_LOCATION; + const distance = km( + { lat: Number(origin.latitude), lng: Number(origin.longitude) }, + { lat: Number(row.latitude), lng: Number(row.longitude) } + ); + + return { + ...row, + __pickup: pickup, + __pickupFellBack: customerPickup && !pickup, + distance, + totalcharge: calculateTotalCharge(distance, RATE.basePrice, RATE.pricePerKm, RATE.minKm) + }; + }); + +const anchors = buildAnchors({ hubs: [DOORMILE_HUB], tenantLocations: [BUSINESS_LOCATION] }); + +const payloadsFor = (mode) => + buildBulkBookingPayloads({ + rows: processRows(parseFixture().rows, { customerPickup: mode === 'customer' }), + sharedPickup: BUSINESS_LOCATION, + tenantId: 4, + tenantLocationId: 31, + deliverytime: DELIVERY_TIME, + notes: 'Lunch run', + anchors + }); + +describe('bulk upload — Jayanthi_Kitchen_Route.xls', () => { + describe('the file itself', () => { + it('should be a single sheet of 13 delivery rows', () => { + const { raw } = parseFixture(); + expect(raw).toHaveLength(13); + }); + + it('should carry three distinct sender kitchens, not one', () => { + // The premise of the whole feature. If this ever collapses to one, the + // fixture has been replaced and these tests no longer prove anything. + const { rows } = parseFixture(); + const senders = new Set(rows.map((r) => r.locationaddress)); + expect(senders.size).toBe(3); + expect([...senders].sort()).toEqual([ + 'Edayarpalayam, Coimbatore, Tamil Nadu 641025', + 'Kavundampalayam, 641030', + 'Ramakrishnamill, Ganapathy, Coimbatore - 641006' + ]); + }); + + it('should split as 6 / 3 / 4 across the three kitchens', () => { + const { rows } = parseFixture(); + const counts = rows.reduce((acc, r) => { + acc[r.locationname] = (acc[r.locationname] || 0) + 1; + return acc; + }, {}); + expect(counts).toEqual({ Jayanthi: 6, Latha: 3, Manjusha: 4 }); + }); + }); + + describe('header mapping', () => { + it('should map every sheet column the app knows about', () => { + const { raw } = parseFixture(); + const normalizedMap = {}; + for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key]; + + const unmapped = Object.keys(raw[0]).filter((h) => !normalizedMap[normalizeHeader(h)]); + // "Address id" is the tenant's own upstream record id. It is carried + // through unmapped and unused, which is correct — it means nothing here. + expect(unmapped).toEqual(['Address id']); + }); + + it('should tolerate the whitespace and casing the real header row carries', () => { + // The live header is "Pickup Date (YYYY-MMM-DD )" — note the space before + // the bracket. normalizeHeader strips ALL whitespace, which is the only + // reason this column resolves; a mapping keyed on the literal string + // would miss it. + expect(normalizeHeader('Pickup Date (YYYY-MMM-DD )')).toBe('pickupdate(yyyy-mmm-dd)'); + expect(SHEET_HEADER_MAP['pickupdate(yyyy-mmm-dd)']).toBe('date'); + }); + + it('should report no missing required columns', () => { + const { headers } = parseFixture(); + const missing = requiredSheetColumns().filter((c) => !headers.includes(normalizeHeader(c))); + expect(missing).toEqual([]); + }); + + it('should give every row a delivery address and usable coordinates', () => { + const { rows } = parseFixture(); + rows.forEach((row) => { + expect(String(row.address || '').trim()).not.toBe(''); + expect(Number(row.latitude)).toBeGreaterThan(10); + expect(Number(row.longitude)).toBeGreaterThan(76); + }); + }); + + it('should leave delivery city and pincode empty, because the sheet has no such columns', () => { + // Documented rather than asserted-away. bulkOrderColumns maps + // `receivercity`/`receiverpincode` speculatively — this real file proves + // the tenant does not send them, so deliverycity/deliverypincode go to the + // backend blank on every row of every upload from this tenant. + const { rows } = parseFixture(); + expect(rows.every((r) => !r.city)).toBe(true); + expect(rows.every((r) => !r.postcode)).toBe(true); + }); + }); + + describe('parcels — quantity and description', () => { + it('should expand quantity into one parcel entry per item', () => { + // Not cosmetic: the app reads quantity back as `parcels.length` + // everywhere downstream, so a 5-item order collapsed to one entry tells + // the rider to collect one bag. + expect(buildParcels({ quantity: 5, description: 'Low GI Carb Meal' })).toHaveLength(5); + expect(buildParcels({ quantity: 1 })).toHaveLength(1); + }); + + it('should never emit zero parcels, whatever the sheet says', () => { + [0, -3, '', null, undefined, 'abc', 1.4].forEach((quantity) => { + expect(buildParcels({ quantity }).length).toBeGreaterThanOrEqual(1); + }); + }); + + it("should carry the sheet's own item description, not a placeholder", () => { + const [parcel] = buildParcels({ quantity: 1, description: 'Economy Lunch Lunch - Jayanthi-Delivery' }); + expect(parcel.itemdescription).toBe('Economy Lunch Lunch - Jayanthi-Delivery'); + }); + + it('should fall back to a generic description only when the sheet gives none', () => { + expect(buildParcels({ quantity: 1, description: ' ' })[0].itemdescription).toBe('Order'); + }); + + it('should produce 19 parcels across the 13 rows of the real file', () => { + // 2+1+1+2+1+1+1+5+1+1+1+1+1 = 19. Before quantity was honoured this was 13. + const total = payloadsFor('hub').reduce((sum, b) => sum + b.parcels.length, 0); + expect(total).toBe(19); + }); + }); + + describe('hub mode — every row collected from the one selected location', () => { + const payloads = payloadsFor('hub'); + + it('should produce one booking per sheet row', () => { + expect(payloads).toHaveLength(13); + }); + + it('should write the business location as the pickup on every row', () => { + payloads.forEach((b) => { + expect(b.pickupaddress).toBe(BUSINESS_LOCATION.address); + expect(b.pickuplatitude).toBe(BUSINESS_LOCATION.latitude); + expect(b.pickuplongitude).toBe(BUSINESS_LOCATION.longitude); + }); + }); + + it('should classify every row as an ordinary hub run', () => { + payloads.forEach((b) => expect(b.ordertype).toBe(FLOW.FORWARD)); + }); + + it('should carry each receiver through to the booking', () => { + expect(payloads[0].customer_name).toBe('Lalitha'); + expect(payloads[0].customer_phone).toBe('9843174920'); + expect(payloads[0].deliveryaddress).toContain('Lalitha boutique 36 Railway mens colony'); + expect(payloads[0].deliverylatitude).toBeCloseTo(11.0506991, 6); + }); + + it('should send phone numbers as strings even though the sheet types them as numbers', () => { + // XLSX parses a phone cell as a Number. A number on the wire where the + // backend expects a string is the kind of thing that 400s the whole batch. + payloads.forEach((b) => { + expect(typeof b.customer_phone).toBe('string'); + expect(b.customer_phone).toMatch(/^\d{10}$/); + }); + }); + + it('should price every row and never send a zero or NaN charge', () => { + payloads.forEach((b) => { + expect(Number.isFinite(b.finalprice)).toBe(true); + expect(b.finalprice).toBeGreaterThanOrEqual(RATE.basePrice); + }); + }); + + it('should stamp the servicing hub so the orders land in a zone', () => { + payloads.forEach((b) => { + expect(b.sourcehubid).toBe(7); + expect(b.hubid).toBe(7); + expect(b.tenantlocationid).toBe(31); + }); + }); + }); + + describe('customer-pickup mode — the case this file actually is', () => { + const payloads = payloadsFor('customer'); + + it('should give each row the pickup its OWN sender column names', () => { + // The defect this replaces: all 13 previously carried Jayanthi's address. + const byAddress = payloads.reduce((acc, b) => { + acc[b.pickupaddress] = (acc[b.pickupaddress] || 0) + 1; + return acc; + }, {}); + expect(byAddress).toEqual({ + 'Edayarpalayam, Coimbatore, Tamil Nadu 641025': 6, + 'Kavundampalayam, 641030': 3, + 'Ramakrishnamill, Ganapathy, Coimbatore - 641006': 4 + }); + }); + + it('should not write one origin across rows that came from another kitchen', () => { + // Row 8 is Kishore, collected at Latha's kitchen in Kavundampalayam. + const kishore = payloads.find((b) => b.customer_name === 'Kishore'); + expect(kishore.pickupaddress).toBe('Kavundampalayam, 641030'); + expect(kishore.pickuplatitude).toBeCloseTo(11.0447, 4); + expect(kishore.pickuplatitude).not.toBe(BUSINESS_LOCATION.latitude); + }); + + it('should give every row real pickup coordinates, never 0,0', () => { + payloads.forEach((b) => { + expect(b.pickuplatitude).not.toBe(0); + expect(b.pickuplongitude).not.toBe(0); + }); + }); + + it('should classify a kitchen that IS a registered location as a hub run, not a doorstep', () => { + // Jayanthi's kitchen is the tenant's registered business location. Rows + // leaving it are hub pickups whatever mode the operator chose — the flow + // follows the facts, not the toggle. + const jayanthi = payloads.filter( + (b) => b.pickupaddress === 'Edayarpalayam, Coimbatore, Tamil Nadu 641025' + ); + expect(jayanthi).toHaveLength(6); + jayanthi.forEach((b) => expect(b.ordertype).toBe(FLOW.FORWARD)); + }); + + it("should classify the two UNREGISTERED kitchens as point-to-point collections", () => { + const others = payloads.filter( + (b) => b.pickupaddress !== 'Edayarpalayam, Coimbatore, Tamil Nadu 641025' + ); + expect(others).toHaveLength(7); + others.forEach((b) => expect(b.ordertype).toBe(FLOW.P2P)); + }); + + it('should price each row from ITS OWN kitchen, not from the shared hub', () => { + // The pricing consequence. Measuring every row from one hub bills a + // cross-town leg no rider ever makes. + const hub = payloadsFor('hub'); + const kishoreHub = hub.find((b) => b.customer_name === 'Kishore'); + const kishoreOwn = payloads.find((b) => b.customer_name === 'Kishore'); + expect(kishoreOwn.finalprice).not.toBe(kishoreHub.finalprice); + }); + }); + + describe('collection points on the dispatch board', () => { + /* Bookings as the board would read them back after creation. */ + const asBookings = payloadsFor('customer').map((b, i) => ({ + bookingid: 1000 + i, + pickuplatitude: b.pickuplatitude, + pickuplongitude: b.pickuplongitude, + deliverylatitude: b.deliverylatitude, + deliverylongitude: b.deliverylongitude, + pickupaddress: b.pickupaddress + })); + + it('should draw exactly three collection pins, not thirteen and not one', () => { + // One pin per real kitchen. Grouping per booking would stack six markers + // on Jayanthi's door; grouping by tenant name — what the console did + // before — would draw a single pin for all three. + const keys = new Set(asBookings.map((b) => classifyBooking(b, anchors).pickupPointKey)); + expect(keys.size).toBe(3); + }); + + it('should group the six Jayanthi rows onto one pin', () => { + const jayanthiKeys = new Set( + asBookings + .filter((b) => b.pickupaddress === 'Edayarpalayam, Coimbatore, Tamil Nadu 641025') + .map((b) => classifyBooking(b, anchors).pickupPointKey) + ); + expect(jayanthiKeys.size).toBe(1); + }); + + it('should keep the three kitchens on separate pins', () => { + const byAddress = new Map(); + asBookings.forEach((b) => { + byAddress.set(b.pickupaddress, classifyBooking(b, anchors).pickupPointKey); + }); + expect(new Set(byAddress.values()).size).toBe(3); + }); + }); + + describe('regression guards', () => { + it('should never lose a row between sheet and wire', () => { + const { raw } = parseFixture(); + expect(payloadsFor('hub')).toHaveLength(raw.length); + expect(payloadsFor('customer')).toHaveLength(raw.length); + }); + + it('should keep sheet order, so the preview an operator approved is what is sent', () => { + const { rows } = parseFixture(); + const names = payloadsFor('customer').map((b) => b.customer_name); + expect(names).toEqual(rows.map((r) => r.firstname)); + }); + + it('should put the same delivery time on every booking in one run', () => { + payloadsFor('hub').forEach((b) => expect(b.deliverytime).toBe(DELIVERY_TIME)); + }); + + it('should apply the run-level note to every booking', () => { + payloadsFor('hub').forEach((b) => expect(b.notes).toBe('Lunch run')); + }); + + it('should send a numeric tenantid, not the string the form holds', () => { + payloadsFor('hub').forEach((b) => expect(b.tenantid).toBe(4)); + }); + + it('should fall back to the shared hub when a sender address will not geocode', () => { + // The row is still booked — dropping it silently would be worse — but it + // is booked at the hub, and the page tells the operator how many did this. + const rows = processRows(parseFixture().rows, { customerPickup: true }).map((r) => + r.customer_name === 'Kishore' ? r : { ...r, __pickup: null, __pickupFellBack: true } + ); + const payloads = buildBulkBookingPayloads({ + rows, + sharedPickup: BUSINESS_LOCATION, + tenantId: 4, + tenantLocationId: 31, + deliverytime: DELIVERY_TIME, + anchors + }); + expect(payloads).toHaveLength(13); + payloads.forEach((b) => { + expect(b.pickupaddress).toBe(BUSINESS_LOCATION.address); + expect(b.ordertype).toBe(FLOW.FORWARD); + }); + }); + }); +}); diff --git a/tests/integration/logisticsBulkUpload.test.js b/tests/integration/logisticsBulkUpload.test.js new file mode 100644 index 0000000..324486f --- /dev/null +++ b/tests/integration/logisticsBulkUpload.test.js @@ -0,0 +1,298 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as XLSX from 'xlsx'; + +import { SHEET_HEADER_MAP, cleanReceiverName, normalizeHeader, requiredSheetColumns } from '@/lib/bulkOrderColumns'; +import { buildBulkBookingPayloads } from '@/lib/bulkOrderPayload'; +import { PICKUP_SOURCE_TYPE, buildAnchors, classifyBooking } from '@/lib/orderFlow'; + +/** + * Bulk upload for the LOGISTICS path — the base-handover flow. + * + * `tests/fixtures/doormile-logistics-test.xlsx` exists because the tenant's own + * export (jayanthi-kitchen-route.xls, covered by bulkUpload.test.js) is 13 lunch + * drops entirely inside Coimbatore. Every row of it is hyperlocal, so uploading + * it exercises none of the intercity path: no parcel is ever routed to a base, + * and the rider is never told to hand one over. + * + * This fixture keeps that file's exact column titles — so it travels through + * the same header map and the same page code — and varies only the delivery + * end, which is what the routing decision is made from. 16 rows: 11 that must + * route through a base, 5 that must go straight to the customer. + * + * WHAT THIS SUITE DOES AND DOES NOT ASSERT. + * + * The routing decision is the BACKEND's, made in Go by isHyperlocalBooking. + * Re-implementing that rule here would only prove that two copies of it agree + * with each other today — the exact trap this repo's other suites call out. So + * the destinations are asserted against the real rule in + * doormile_backend/controllers/logisticsRouting_test.go, and what is asserted + * HERE is what the console is actually responsible for: that every row survives + * the sheet-to-wire transform with the fields the backend needs to make that + * decision, and that the pickup source type goes out correctly. + */ + +const FIXTURE = path.join(__dirname, '..', 'fixtures', 'doormile-logistics-test.xlsx'); + +// The tenant's selected pickup location — Jayanthi's kitchen, which is also a +// registered site, at the same coordinates as the Coimbatore hub. +const BUSINESS_LOCATION = { + tenantlocationid: 31, + tenantid: 4, + hubid: 7, + locationname: 'Jayanthi Kitchen', + address: 'Edayarpalayam, Coimbatore, Tamil Nadu 641025', + city: 'Coimbatore', + pincode: '641025', + latitude: 11.0168, + longitude: 76.9558 +}; + +const DOORMILE_BASE = { + hubid: 7, + hubname: 'Coimbatore Gandhipuram Hub', + address: '14 Avinashi Road, Peelamedu, Coimbatore', + pincode: '641004', + latitude: 11.0272, + longitude: 76.9905 +}; + +const DELIVERY_TIME = '2026-09-02 09:00:00'; + +/** The page's own parse (MultipleOrders.processData), with the real map. */ +const parseFixture = () => { + const wb = XLSX.read(fs.readFileSync(FIXTURE), { type: 'buffer' }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const raw = XLSX.utils.sheet_to_json(sheet, { defval: '' }); + + const normalizedMap = {}; + for (const key in SHEET_HEADER_MAP) normalizedMap[normalizeHeader(key)] = SHEET_HEADER_MAP[key]; + + const rows = raw.map((row, idx) => { + const out = { _rowkey: `csv-${idx}`, quantity: 1, collectionamt: 0 }; + for (const key in row) { + const clean = normalizeHeader(key); + const mapped = normalizedMap[clean] || clean; + out[mapped] = mapped === 'firstname' ? cleanReceiverName(row[key]) : row[key]; + } + return out; + }); + + return { raw, rows, headers: Object.keys(raw[0]).map(normalizeHeader) }; +}; + +const processRows = (rows) => + rows.map((row) => ({ ...row, __pickup: null, distance: 10, totalcharge: 120 })); + +const anchors = buildAnchors({ hubs: [DOORMILE_BASE], tenantLocations: [BUSINESS_LOCATION] }); + +const payloads = buildBulkBookingPayloads({ + rows: processRows(parseFixture().rows), + sharedPickup: BUSINESS_LOCATION, + tenantId: 4, + tenantLocationId: 31, + deliverytime: DELIVERY_TIME, + anchors +}); + +/** Same rule the backend applies, stated once here ONLY to partition the + * fixture for counting — never to assert an individual row's routing, which is + * the backend test's job. */ +const differentPostalArea = (p) => + !(String(p.pickuppincode).length >= 3 && + String(p.deliverypincode).length >= 3 && + String(p.pickuppincode).slice(0, 3) === String(p.deliverypincode).slice(0, 3)); + +describe('logistics bulk upload — doormile-logistics-test.xlsx', () => { + describe('the fixture itself', () => { + it('should be a single sheet of 16 rows', () => { + const { raw } = parseFixture(); + expect(raw).toHaveLength(16); + }); + + it('should use the tenant sheet\'s own column titles, so nothing new has to be learned', () => { + const { headers } = parseFixture(); + ['sendername*', 'senderphone*', 'senderaddress*', 'receivername*', 'receiveralternatephone*', 'itemdescription*'] + .forEach((h) => expect(headers).toContain(h)); + }); + + it('should report no missing required columns', () => { + const { headers } = parseFixture(); + const missing = requiredSheetColumns().filter((c) => !headers.includes(normalizeHeader(c))); + expect(missing).toEqual([]); + }); + + it('should carry a pincode column, which the tenant file does not', () => { + // Without one, every row lands on the backend's distance fallback and the + // prefix rule — the thing that actually decides intercity — never runs. + const { headers } = parseFixture(); + expect(headers).toContain('receiverpincode'); + expect(SHEET_HEADER_MAP.receiverpincode).toBe('postcode'); + }); + + it('should leave from ONE origin, so the delivery end is the only variable', () => { + const { rows } = parseFixture(); + const senders = new Set(rows.map((r) => String(r.locationaddress).trim())); + expect(senders.size).toBe(1); + }); + + it('should actually split both ways, or it is testing nothing', () => { + // A fixture that turned out to be all-hyperlocal would pass every other + // assertion here and still exercise none of the handover path — which is + // precisely what is wrong with the tenant's own export. + const viaBase = payloads.filter(differentPostalArea); + expect(viaBase.length).toBeGreaterThanOrEqual(10); + expect(payloads.length - viaBase.length).toBeGreaterThanOrEqual(4); + }); + }); + + describe('what the backend needs in order to route at all', () => { + it('should produce one booking per sheet row, losing none', () => { + expect(payloads).toHaveLength(16); + }); + + it('should give every row a pickup pincode, or nothing can be compared', () => { + payloads.forEach((p) => expect(String(p.pickuppincode)).toHaveLength(6)); + }); + + it('should give every row real pickup coordinates, never 0,0', () => { + payloads.forEach((p) => { + expect(p.pickuplatitude).not.toBe(0); + expect(p.pickuplongitude).not.toBe(0); + }); + }); + + it('should give every row delivery coordinates, so the distance fallback can run', () => { + // Three rows deliberately carry no usable pincode. For those, coordinates + // are the ONLY thing the backend can decide from. + payloads.forEach((p) => { + expect(Number(p.deliverylatitude)).not.toBe(0); + expect(Number(p.deliverylongitude)).not.toBe(0); + }); + }); + + it('should carry the delivery pincode through as a string, not a number', () => { + // A pincode read as a number loses a leading zero and stops being 6 long, + // which silently changes which 3-digit area it compares as. + payloads.filter((p) => p.deliverypincode !== '').forEach((p) => { + expect(typeof p.deliverypincode).toBe('string'); + }); + }); + + it('should preserve the rows that intentionally have no usable pincode', () => { + // These are the fallback cases and must NOT be quietly filled in. + const unusable = payloads.filter((p) => String(p.deliverypincode).length < 3); + expect(unusable.length).toBe(3); + }); + }); + + describe('pickup source — what kind of place these leave from', () => { + it('should name a source type on every single booking', () => { + // "customer" is a value, never an omission — an absent field reads as + // "nobody filled this in", which is a different fact from "front door". + payloads.forEach((p) => { + expect(p.pickup_source_type).toBeDefined(); + expect(Object.values(PICKUP_SOURCE_TYPE)).toContain(p.pickup_source_type); + }); + }); + + it('should call a collection at the client\'s registered site a merchant pickup', () => { + // Jayanthi's kitchen is a registered tenant location. Geometrically it is + // a facility, but to a rider it is a shop, not a base — and titling the + // stop "base" is what made every logistics pickup read as the rider's own + // office. + payloads.forEach((p) => expect(p.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT)); + }); + + it('should not name a base on a pickup that is not from one', () => { + payloads.forEach((p) => expect(p.pickuphubid).toBeUndefined()); + }); + + it('should stamp the servicing base so the orders land in a zone', () => { + payloads.forEach((p) => { + expect(p.sourcehubid).toBe(DOORMILE_BASE.hubid); + expect(p.hubid).toBe(DOORMILE_BASE.hubid); + }); + }); + + it('should read back as a merchant pickup once the row returns from the server', () => { + // The write path and the read path must tell the same story. + const readBack = classifyBooking( + { ...payloads[0], bookingid: 1, pickup_source_type: payloads[0].pickup_source_type }, + anchors + ); + expect(readBack.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.MERCHANT); + expect(readBack.sourceTypeFromServer).toBe(true); + }); + }); + + describe('a base-origin upload — the Base → Customer case', () => { + // Same sheet, but the operator has selected a BASE as the pickup point + // rather than a client site. This is the AC-2 journey: Home must show the + // base as the actual pickup source. + const fromBase = buildBulkBookingPayloads({ + rows: processRows(parseFixture().rows), + sharedPickup: { + ...DOORMILE_BASE, + latitude: DOORMILE_BASE.latitude, + longitude: DOORMILE_BASE.longitude, + pincode: DOORMILE_BASE.pincode, + address: DOORMILE_BASE.address + }, + tenantId: 4, + tenantLocationId: '', + deliverytime: DELIVERY_TIME, + anchors + }); + + it('should classify every row as a base pickup', () => { + fromBase.forEach((p) => expect(p.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB)); + }); + + it('should carry the base id, which is what keys the rider handover', () => { + fromBase.forEach((p) => expect(p.pickuphubid).toBe(DOORMILE_BASE.hubid)); + }); + + it('should put the base address on the booking, not a customer door', () => { + fromBase.forEach((p) => expect(p.pickupaddress).toBe(DOORMILE_BASE.address)); + }); + }); + + describe('the rest of the row survives the transform', () => { + it('should carry every receiver name through in sheet order', () => { + const { rows } = parseFixture(); + payloads.forEach((p, i) => expect(p.customer_name).toBe(rows[i].firstname)); + }); + + it('should send phone numbers as strings even though the sheet types them as numbers', () => { + payloads.forEach((p) => expect(typeof p.customer_phone).toBe('string')); + }); + + it('should expand quantity into one parcel entry per item', () => { + const { rows } = parseFixture(); + payloads.forEach((p, i) => { + expect(p.parcels.length).toBe(Math.max(1, Number(rows[i].quantity) || 1)); + }); + }); + + it('should keep the COD rows, which must survive a base leg', () => { + // Three rows carry cash to collect. A parcel handed over at a base and + // carried onward still has money attached to it at the far end. + const { rows } = parseFixture(); + const cod = rows.filter((r) => Number(r.collectionamt) > 0); + expect(cod.length).toBe(3); + }); + + it('should put the same delivery time on every booking in one run', () => { + payloads.forEach((p) => expect(p.deliverytime).toBe(DELIVERY_TIME)); + }); + + it('should ignore the human-readable notes column rather than send it', () => { + // The sheet carries an "Expected Routing" column for whoever is running + // the test by hand. It must not reach the wire. + payloads.forEach((p) => { + expect(Object.keys(p).some((k) => k.toLowerCase().includes('expected'))).toBe(false); + }); + }); + }); +}); diff --git a/tests/lib/DataTable.test.jsx b/tests/lib/DataTable.test.jsx new file mode 100644 index 0000000..0314af6 --- /dev/null +++ b/tests/lib/DataTable.test.jsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +jest.mock('lucide-react', () => ({ + Check: () => , + Minus: () => , + ArrowDown: () => , + ArrowUp: () => , + ChevronsUpDown: () => , + Inbox: () => , + ChevronLeft: () => , + ChevronRight: () => , + ChevronsLeft: () => , + ChevronsRight: () => , +})); + +import { DataTable } from '@/components/ds/DataTable'; + +const COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'title', header: 'Title' }, +]; + +const createMockRows = (count = 60) => + Array.from({ length: count }, (_, i) => ({ + id: `order-${i + 1}`, + title: `Order #${i + 1}`, + })); + +function TestTableWrapper({ + rowCount = 60, + pageSize = 25, + selectAllPages = true, + initialSelected = [], +}) { + const [selectedIds, setSelectedIds] = useState(initialSelected); + const rows = createMockRows(rowCount); + + return ( +
    +
    {selectedIds.length}
    + row.id} + pageSize={pageSize} + selectAllPages={selectAllPages} + selectedIds={selectedIds} + onSelectionChange={setSelectedIds} + /> +
    + ); +} + +describe('DataTable selection across pagination', () => { + it('should select ALL 60 rows across all pages when selectAllPages is true', () => { + render(); + + expect(screen.getByTestId('selected-count').textContent).toBe('0'); + + // Header checkbox for select all + const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i }); + expect(selectAllCheckbox).not.toBeChecked(); + + // Click select all + fireEvent.click(selectAllCheckbox); + + // All 60 rows should now be selected + expect(screen.getByTestId('selected-count').textContent).toBe('60'); + expect(selectAllCheckbox).toBeChecked(); + + // Click again to unselect all + fireEvent.click(selectAllCheckbox); + expect(screen.getByTestId('selected-count').textContent).toBe('0'); + expect(selectAllCheckbox).not.toBeChecked(); + }); + + it('should select ONLY current page (25 rows) when selectAllPages is false', () => { + render(); + + expect(screen.getByTestId('selected-count').textContent).toBe('0'); + + const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows on this page/i }); + fireEvent.click(selectAllCheckbox); + + // Only the 25 rows on page 1 should be selected + expect(screen.getByTestId('selected-count').textContent).toBe('25'); + }); + + it('should reflect indeterminate state when some rows are selected', () => { + render( + + ); + + expect(screen.getByTestId('selected-count').textContent).toBe('3'); + const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i }); + + expect(selectAllCheckbox.getAttribute('data-state') === 'indeterminate' || selectAllCheckbox.indeterminate).toBeTruthy(); + }); + + it('should toggle individual row selection without affecting other pages', () => { + render(); + + const rowCheckboxes = screen.getAllByRole('checkbox'); + // First checkbox is header, second is row 1 + const firstRowCheckbox = rowCheckboxes[1]; + + fireEvent.click(firstRowCheckbox); + expect(screen.getByTestId('selected-count').textContent).toBe('1'); + + fireEvent.click(firstRowCheckbox); + expect(screen.getByTestId('selected-count').textContent).toBe('0'); + }); +}); diff --git a/tests/lib/bookingDrops.test.js b/tests/lib/bookingDrops.test.js new file mode 100644 index 0000000..93d851c --- /dev/null +++ b/tests/lib/bookingDrops.test.js @@ -0,0 +1,81 @@ +import { dropCountOf, dropSummary, isMultiDrop, plural } from '@/lib/bookingDrops'; + +describe('dropCountOf', () => { + // The console can ship before or after the backend that adds these fields. + // An absent count must read as 0 so every multi-drop behaviour stays off and + // the page renders exactly as it did. + it('treats an absent destinationcount as zero', () => { + expect(dropCountOf({})).toBe(0); + expect(dropCountOf({ destinationcount: undefined })).toBe(0); + expect(dropCountOf(undefined)).toBe(0); + expect(dropCountOf(null)).toBe(0); + }); + + it('reads the count the backend sent', () => { + expect(dropCountOf({ destinationcount: 3 })).toBe(3); + expect(dropCountOf({ destinationcount: 1 })).toBe(1); + }); + + // A console-created booking has no bookingdestinations rows at all. + it('keeps a console-created booking at zero', () => { + expect(dropCountOf({ destinationcount: 0 })).toBe(0); + }); +}); + +describe('isMultiDrop', () => { + // THE IMPORTANT ONE. This gate is what stops the Orders row rendering the + // booking's own consignmentstatus — which is the FIRST leg's status wearing + // the whole booking's name — as the status of a three-drop pickup. + it('is true only above one destination', () => { + expect(isMultiDrop({ destinationcount: 2 })).toBe(true); + expect(isMultiDrop({ destinationcount: 3 })).toBe(true); + }); + + it('is false for a single drop, so the row keeps todays behaviour', () => { + expect(isMultiDrop({ destinationcount: 1 })).toBe(false); + }); + + it('is false when the backend has not shipped the field yet', () => { + expect(isMultiDrop({})).toBe(false); + expect(isMultiDrop({ bookingid: 7, status: 'converted_to_consignment' })).toBe(false); + }); + + it('is false for a console-created booking reporting zero', () => { + expect(isMultiDrop({ destinationcount: 0 })).toBe(false); + }); +}); + +describe('dropSummary', () => { + it('reads as the handoff specifies', () => { + expect(dropSummary({ destinationcount: 3, totalpackagecount: 4 })).toBe( + '3 destinations · 4 packages' + ); + }); + + // Every destination carries at least one package, so a zero package total is + // a stale or partial response rather than a real booking. Falling back to the + // destination count keeps the line truthful instead of printing "0 packages". + it('falls back to the destination count when the package sum is missing', () => { + expect(dropSummary({ destinationcount: 3 })).toBe('3 destinations · 3 packages'); + expect(dropSummary({ destinationcount: 2, totalpackagecount: 0 })).toBe( + '2 destinations · 2 packages' + ); + }); + + it('pluralises both nouns independently', () => { + expect(dropSummary({ destinationcount: 2, totalpackagecount: 1 })).toBe( + '2 destinations · 1 package' + ); + expect(dropSummary({ destinationcount: 1, totalpackagecount: 2 })).toBe( + '1 destination · 2 packages' + ); + }); +}); + +describe('plural', () => { + it('keeps the singular at exactly one', () => { + expect(plural(1, 'package')).toBe('1 package'); + expect(plural(0, 'package')).toBe('0 packages'); + expect(plural(2, 'destination')).toBe('2 destinations'); + }); +}); diff --git a/tests/lib/customerAppBookings.test.js b/tests/lib/customerAppBookings.test.js new file mode 100644 index 0000000..de132cf --- /dev/null +++ b/tests/lib/customerAppBookings.test.js @@ -0,0 +1,173 @@ +import { + BOOKING_SOURCE, + BOOKING_TABS, + bookingsForTab, + customerAppBookings, + isCustomerAppBooking, + quotedPrice, + serviceOptionOf, + serviceType, +} from '@/lib/customerAppBookings'; + +/** + * Which bookings belong on the Bookings page. + * + * The page shows what customers booked in the app; Orders shows what this + * console created. `bookingsource` is the only field that separates them, and + * the trap is its second value: the API and routes were renamed from "CRM" to + * "express", but the STORED column value was deliberately left as + * `"CRM_Console"` because it is existing data rather than a label. Matching on + * "express" would silently show every console order on the customer page. + */ + +const appBooking = (over = {}) => ({ + bookingid: 1, + bookingno: 'BK-1', + bookingsource: 'Customer_App', + status: 'Pending_Pickup', + ...over, +}); + +const consoleBooking = (over = {}) => ({ + bookingid: 2, + bookingno: 'BK-2', + bookingsource: 'CRM_Console', + status: 'Pending_Pickup', + ...over, +}); + +describe('isCustomerAppBooking', () => { + it('should accept a booking made in the app', () => { + expect(isCustomerAppBooking(appBooking())).toBe(true); + }); + + it('should reject a console-created booking', () => { + expect(isCustomerAppBooking(consoleBooking())).toBe(false); + }); + + it('should still reject the console value under its post-rename name', () => { + // The outward naming became "express"; the stored value never did. Anything + // that starts matching on "express" here has misunderstood that. + expect(BOOKING_SOURCE.CONSOLE).toBe('CRM_Console'); + expect(isCustomerAppBooking({ bookingsource: 'express' })).toBe(false); + }); + + it('should tolerate casing and stray whitespace', () => { + // Compared against a raw database column, not a validated enum. + expect(isCustomerAppBooking({ bookingsource: ' customer_app ' })).toBe(true); + expect(isCustomerAppBooking({ bookingsource: 'CUSTOMER_APP' })).toBe(true); + }); + + it('should not claim a booking with no source', () => { + // Filing unknown rows under "customer app" would overstate B2C volume, and + // that is a number people make decisions on. + expect(isCustomerAppBooking({})).toBe(false); + expect(isCustomerAppBooking({ bookingsource: '' })).toBe(false); + expect(isCustomerAppBooking(null)).toBe(false); + expect(isCustomerAppBooking(undefined)).toBe(false); + }); +}); + +describe('customerAppBookings', () => { + it('should keep only app bookings and drop console ones', () => { + const list = [appBooking({ bookingid: 1 }), consoleBooking({ bookingid: 2 }), appBooking({ bookingid: 3 })]; + expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([3, 1]); + }); + + it('should order newest first', () => { + const list = [appBooking({ bookingid: 5 }), appBooking({ bookingid: 42 }), appBooking({ bookingid: 12 })]; + expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([42, 12, 5]); + }); + + it('should not mutate the list it was given', () => { + // The same array is the Orders page's cache entry — sorting it in place + // would reorder another page's rows underneath it. + const list = [appBooking({ bookingid: 1 }), appBooking({ bookingid: 9 })]; + customerAppBookings(list); + expect(list.map((b) => b.bookingid)).toEqual([1, 9]); + }); + + it('should return an empty list rather than throw on nothing', () => { + expect(customerAppBookings(undefined)).toEqual([]); + expect(customerAppBookings(null)).toEqual([]); + expect(customerAppBookings([])).toEqual([]); + }); +}); + +describe('the price quote', () => { + const withOptions = (options) => appBooking({ serviceoptions: options }); + + it('should read the service option the booking carries', () => { + const b = withOptions([{ servicetype: 'Fast', estimatedprice: 210, createdat: '2026-09-01T10:00:00Z' }]); + expect(serviceType(b)).toBe('Fast'); + expect(quotedPrice(b)).toBe(210); + }); + + it('should take the newest option when a booking has several', () => { + // Matches the backend's own `ORDER BY createdat DESC LIMIT 1`, so the page + // and GET /customer/bookings/{id}/price cannot disagree. + const b = withOptions([ + { servicetype: 'Normal', estimatedprice: 100, createdat: '2026-09-01T10:00:00Z' }, + { servicetype: 'Superfast', estimatedprice: 300, createdat: '2026-09-02T10:00:00Z' }, + ]); + expect(serviceOptionOf(b).servicetype).toBe('Superfast'); + expect(quotedPrice(b)).toBe(300); + }); + + it('should report no quote rather than a zero price', () => { + // A booking with no service option has no price. Rendering 0 would read as + // "free", which is a different claim. + expect(serviceOptionOf(appBooking())).toBeNull(); + expect(serviceOptionOf(withOptions([]))).toBeNull(); + expect(serviceType(appBooking())).toBe(''); + }); + + it('should not throw on a missing booking', () => { + expect(() => quotedPrice(undefined)).not.toThrow(); + expect(quotedPrice(undefined)).toBe(0); + }); +}); + +describe('the status tabs', () => { + it('should let the All tab through untouched', () => { + const list = [appBooking({ status: 'Pending_Pickup' }), appBooking({ status: 'Cancelled' })]; + expect(bookingsForTab(list, 'all')).toHaveLength(2); + }); + + it('should put an uncollected unassigned booking under Created tab', () => { + const list = [ + appBooking({ bookingid: 1, status: 'Pending_Pickup' }), + appBooking({ bookingid: 2, status: 'Created' }), + appBooking({ bookingid: 3, status: 'Miler_Assigned' }), + ]; + expect(bookingsForTab(list, 'created').map((b) => b.bookingid)).toEqual([1, 2]); + }); + + it('should put an assigned / in-progress booking under Rider Assigned tab', () => { + const list = [ + appBooking({ bookingid: 1, status: 'Miler_Assigned' }), + appBooking({ bookingid: 2, status: 'Converted_To_Consignment' }), + appBooking({ bookingid: 3, status: 'Pending_Pickup' }), + ]; + expect(bookingsForTab(list, 'assigned').map((b) => b.bookingid)).toEqual([1, 2]); + }); + + it('should match status case-insensitively', () => { + expect(bookingsForTab([appBooking({ status: 'CANCELLED' })], 'cancelled')).toHaveLength(1); + }); + + it('should fall back to showing everything for an unknown tab', () => { + // An unknown tab value can only come from a bug in the page. Falling back to + // the full list keeps the data visible; silently returning nothing would + // look identical to "this customer has no bookings", which is a lie the + // operator cannot see through. + expect(bookingsForTab([appBooking()], 'nonsense')).toHaveLength(1); + }); + + it('should give every tab a value and a label', () => { + BOOKING_TABS.forEach((t) => { + expect(t.value).toBeTruthy(); + expect(t.label).toBeTruthy(); + }); + }); +}); diff --git a/tests/lib/hubForm.test.js b/tests/lib/hubForm.test.js new file mode 100644 index 0000000..01b7588 --- /dev/null +++ b/tests/lib/hubForm.test.js @@ -0,0 +1,134 @@ +import { validateHubForm } from '@/lib/hubForm'; + +/** + * A hub in this console is a BASE to a rider. + * + * The bug these tests exist to prevent is already in production data: of the 18 + * hubs live today, one carries no address and no coordinates. Only `hubname` + * and `applocationid` were ever validated, so a base could be saved that the + * rider app can name but cannot route anyone to — Navigate does nothing without + * a pin, and a rider standing outside an industrial estate has nothing to read + * without an address. + * + * The backend hands six fields to the rider app for a base. Five of six is not + * "mostly there"; it is a rider with a parcel and no way to deliver it. + */ + +const VALID = { + hubname: 'Coimbatore Gandhipuram Hub', + applocationid: 1, + address: '14 Avinashi Road, Peelamedu, Coimbatore', + pincode: '641004', + latitude: '11.0168', + longitude: '76.9558' +}; + +describe('validateHubForm', () => { + describe('a complete base', () => { + it('should accept a form carrying all six rider-facing fields', () => { + const { errors, isValid } = validateHubForm(VALID); + expect(errors).toEqual({}); + expect(isValid).toBe(true); + }); + + it('should hand back coordinates already coerced, so the caller does not re-parse', () => { + const { values } = validateHubForm(VALID); + expect(values.latitude).toBe(11.0168); + expect(values.longitude).toBe(76.9558); + expect(values.applocationid).toBe(1); + }); + + it('should accept a southern or western hemisphere base', () => { + // Nothing in the range check may assume India. + expect(validateHubForm({ ...VALID, latitude: '-33.8688', longitude: '-70.6693' }).isValid).toBe(true); + }); + }); + + describe('the fields that were never checked', () => { + it('should reject a base with no address, because the rider reads it at the gate', () => { + const { errors, isValid } = validateHubForm({ ...VALID, address: '' }); + expect(isValid).toBe(false); + expect(errors.address).toBeTruthy(); + }); + + it('should reject whitespace as an address', () => { + expect(validateHubForm({ ...VALID, address: ' ' }).errors.address).toBeTruthy(); + }); + + it('should reject a base with no pincode', () => { + expect(validateHubForm({ ...VALID, pincode: '' }).errors.pincode).toBeTruthy(); + }); + + it('should reject a base with no coordinates, because Navigate cannot work', () => { + const { errors, isValid } = validateHubForm({ ...VALID, latitude: '', longitude: '' }); + expect(isValid).toBe(false); + expect(errors.latitude).toBeTruthy(); + expect(errors.longitude).toBeTruthy(); + }); + + it('should treat 0,0 as unset rather than as a location', () => { + // The previous code coerced an empty coordinate to 0 and sent it. 0,0 is + // in the Gulf of Guinea; it is always an unset value, never a base. + const { errors } = validateHubForm({ ...VALID, latitude: '0', longitude: '0' }); + expect(errors.latitude).toBeTruthy(); + expect(errors.longitude).toBeTruthy(); + }); + + it('should reject a single missing coordinate, not just both', () => { + // Half a pin is no pin. + expect(validateHubForm({ ...VALID, latitude: '' }).isValid).toBe(false); + expect(validateHubForm({ ...VALID, longitude: '' }).isValid).toBe(false); + }); + + it('should reject coordinates outside the possible range', () => { + expect(validateHubForm({ ...VALID, latitude: '91' }).errors.latitude).toBeTruthy(); + expect(validateHubForm({ ...VALID, latitude: '-91' }).errors.latitude).toBeTruthy(); + expect(validateHubForm({ ...VALID, longitude: '181' }).errors.longitude).toBeTruthy(); + expect(validateHubForm({ ...VALID, longitude: '-181' }).errors.longitude).toBeTruthy(); + }); + + it('should reject a coordinate that is not a number at all', () => { + expect(validateHubForm({ ...VALID, latitude: 'near the flyover' }).errors.latitude).toBeTruthy(); + }); + + it('should catch a swapped lat/lng pair for an Indian base', () => { + // 76.9 as a latitude is inside the Arctic Circle. The range check is the + // only thing standing between a transposed paste and a base in the sea. + expect(validateHubForm({ ...VALID, latitude: '76.9558', longitude: '11.0168' }).isValid).toBe(true); + // ...but a longitude beyond 90 in the latitude slot is caught outright. + expect(validateHubForm({ ...VALID, latitude: '176.9558' }).errors.latitude).toBeTruthy(); + }); + }); + + describe('the checks that already existed', () => { + it('should still require a name', () => { + expect(validateHubForm({ ...VALID, hubname: '' }).errors.hubname).toBeTruthy(); + expect(validateHubForm({ ...VALID, hubname: ' ' }).errors.hubname).toBeTruthy(); + }); + + it('should still require a numeric city id', () => { + expect(validateHubForm({ ...VALID, applocationid: '' }).errors.applocationid).toBeTruthy(); + expect(validateHubForm({ ...VALID, applocationid: 'Coimbatore' }).errors.applocationid).toBeTruthy(); + }); + + it('should accept city id 0, which is a valid id and not an absence', () => { + expect(validateHubForm({ ...VALID, applocationid: 0 }).errors.applocationid).toBeUndefined(); + }); + }); + + describe('robustness', () => { + it('should report every problem at once rather than one at a time', () => { + // An operator fixing a form field by field, submit by submit, is a worse + // experience than being told everything that is wrong. + const { errors } = validateHubForm({}); + expect(Object.keys(errors).sort()).toEqual( + ['address', 'applocationid', 'hubname', 'latitude', 'longitude', 'pincode'].sort() + ); + }); + + it('should not throw on an empty or missing form', () => { + expect(() => validateHubForm()).not.toThrow(); + expect(validateHubForm().isValid).toBe(false); + }); + }); +}); diff --git a/tests/lib/orderFlow.test.js b/tests/lib/orderFlow.test.js new file mode 100644 index 0000000..3a9bb72 --- /dev/null +++ b/tests/lib/orderFlow.test.js @@ -0,0 +1,480 @@ +import { + ANCHOR_RADIUS_M, + DROP_TARGET, + FLOW, + PICKUP_SOURCE, + PICKUP_SOURCE_TYPE, + buildAnchors, + buildFlowFields, + classifyBooking, + flowForDraft, + flowLabel, + hasCustomerPickup, + hasHubDrop, + matchAnchor, + normalizeFlow +} from '@/lib/orderFlow'; + +/** + * Which end of a booking is a Doormile facility. + * + * The bug these tests exist to prevent: the console assumed the pickup was + * ALWAYS the hub. queries.js used the tenant's name as the collection point's + * identity, so every customer pickup for one client collapsed onto a single map + * pin placed at an arbitrary one of them, and the rider's polyline skipped the + * entire first-mile leg. + * + * Two invariants carry the most weight here and are asserted repeatedly: + * + * 1. UNKNOWABLE MEANS FORWARD. No anchors loaded, no coordinates, a 0,0 + * "unset" — none of these may produce a customer pickup. An order that + * cannot be classified has to look exactly as it did before this module + * existed, or a data problem starts drawing phantom collection points. + * 2. A CUSTOMER PICKUP NEVER SHARES A GROUPING KEY. Two doorsteps are two + * places. Merging them is precisely the defect being replaced. + */ + +// Real Coimbatore-ish coordinates, so the distances under test are the +// magnitudes the radius was actually chosen for. +const HUB = { hubid: 7, hubname: 'Gandhipuram Hub', latitude: 11.0168, longitude: 76.9558 }; +const KITCHEN = { + tenantlocationid: 31, + tenantid: 4, + locationname: '124 Gandhipuram Main Rd', + latitude: 11.021, + longitude: 76.962 +}; + +// ~4 km and ~6 km out — unambiguously somebody's door. +const DOOR_A = { lat: 11.05, lng: 77.0 }; +const DOOR_B = { lat: 11.06, lng: 77.01 }; + +const anchors = buildAnchors({ hubs: [HUB], tenantLocations: [KITCHEN] }); + +const booking = ({ id = 1, from, to, ...rest }) => ({ + bookingid: id, + pickuplatitude: from.lat, + pickuplongitude: from.lng, + deliverylatitude: to.lat, + deliverylongitude: to.lng, + ...rest +}); + +const AT_HUB = { lat: HUB.latitude, lng: HUB.longitude }; +const AT_KITCHEN = { lat: KITCHEN.latitude, lng: KITCHEN.longitude }; + +describe('orderFlow', () => { + describe('buildAnchors', () => { + it('should treat both a Doormile hub and a client location as facilities', () => { + expect(anchors).toHaveLength(2); + expect(anchors.map((a) => a.kind).sort()).toEqual(['hub', 'tenantlocation']); + }); + + it('should give each anchor a key that is unique per facility', () => { + expect(new Set(anchors.map((a) => a.key)).size).toBe(anchors.length); + }); + + it('should drop a facility nobody geocoded rather than keep it with NaN coordinates', () => { + const built = buildAnchors({ hubs: [{ hubid: 9, hubname: 'Unmapped' }, HUB] }); + expect(built.map((a) => a.hubid)).toEqual([7]); + }); + + it('should return an empty set when called with nothing', () => { + expect(buildAnchors()).toEqual([]); + }); + + it('should give a tenant location the hub that services its city', () => { + // A tenant location record has NO hubid field — the API does not carry + // one. Without this, every order originating at a client's own kitchen + // went to the backend with no servicing hub, and zone attribution fell + // straight back to matching hub names against address text. + const kitchen = anchors.find((a) => a.kind === 'tenantlocation'); + expect(kitchen.hubid).toBe(7); + }); + + it('should not invent a servicing hub for a location in another city', () => { + // Chennai, ~400 km away. A wrong hub id is worse than none: it files the + // order into a zone it has nothing to do with, where nobody is looking. + const [faraway] = buildAnchors({ + hubs: [HUB], + tenantLocations: [{ tenantlocationid: 99, locationname: 'Chennai Depot', latitude: 13.0827, longitude: 80.2707 }] + }).filter((a) => a.kind === 'tenantlocation'); + expect(faraway.hubid).toBeNull(); + }); + + it("should keep a location's own hubid when the record already has one", () => { + const [explicit] = buildAnchors({ + hubs: [HUB], + tenantLocations: [{ ...KITCHEN, hubid: 12 }] + }).filter((a) => a.kind === 'tenantlocation'); + expect(explicit.hubid).toBe(12); + }); + + it('should leave hubid null when no hubs were loaded at all', () => { + const [alone] = buildAnchors({ tenantLocations: [KITCHEN] }); + expect(alone.hubid).toBeNull(); + }); + }); + + describe('matchAnchor', () => { + it('should match a point sitting on a facility', () => { + expect(matchAnchor(HUB.latitude, HUB.longitude, anchors)?.hubid).toBe(7); + }); + + it('should not match a customer door several km away', () => { + expect(matchAnchor(DOOR_A.lat, DOOR_A.lng, anchors)).toBeNull(); + }); + + it('should choose the NEAREST facility, not the first one in the array', () => { + // Two facilities sharing a compound is normal. Picking by array order + // would name the same pin differently depending on what /admin/hubs + // happened to return first. + const twinA = { hubid: 1, hubname: 'Sorting Centre', latitude: 11.0168, longitude: 76.9558 }; + const twinB = { hubid: 2, hubname: 'Client Depot', latitude: 11.01685, longitude: 76.95585 }; + const both = buildAnchors({ hubs: [twinA, twinB] }); + expect(matchAnchor(twinB.latitude, twinB.longitude, both)?.hubid).toBe(2); + expect(matchAnchor(twinA.latitude, twinA.longitude, both)?.hubid).toBe(1); + }); + + it('should never match a 0,0 "unset" coordinate against a real facility', () => { + expect(matchAnchor(0, 0, anchors)).toBeNull(); + }); + + it('should treat the radius as a boundary in metres, not degrees', () => { + // ~100 m north of the hub: inside. ~500 m: outside. + const inside = HUB.latitude + 0.0009; + const outside = HUB.latitude + 0.0045; + expect(matchAnchor(inside, HUB.longitude, anchors)).not.toBeNull(); + expect(matchAnchor(outside, HUB.longitude, anchors)).toBeNull(); + expect(ANCHOR_RADIUS_M).toBe(200); + }); + }); + + describe('classifyBooking — the four flows', () => { + it('should classify hub to customer as Forward', () => { + const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors); + expect(r.flow).toBe(FLOW.FORWARD); + expect(r.pickupSource).toBe(PICKUP_SOURCE.HUB); + expect(r.dropTarget).toBe(DROP_TARGET.CUSTOMER); + }); + + it('should classify customer to hub as Reverse — the customer pickup', () => { + const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors); + expect(r.flow).toBe(FLOW.REVERSE); + expect(r.pickupSource).toBe(PICKUP_SOURCE.CUSTOMER); + expect(r.dropTarget).toBe(DROP_TARGET.HUB); + }); + + it('should classify customer to customer as P2P', () => { + const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors); + expect(r.flow).toBe(FLOW.P2P); + }); + + it('should classify hub to hub as Linehaul', () => { + const r = classifyBooking(booking({ from: AT_KITCHEN, to: AT_HUB }), anchors); + expect(r.flow).toBe(FLOW.LINEHAUL); + }); + }); + + describe('classifyBooking — unknowable input stays Forward', () => { + it('should fall back to Forward when no facility list has loaded', () => { + // Geometry proves nothing without anchors. Anything but Forward here + // would relabel the whole board during the first render, before + // /admin/hubs has answered. + expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), []).flow).toBe(FLOW.FORWARD); + expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B })).flow).toBe(FLOW.FORWARD); + }); + + it('should fall back to Forward for a pickup that failed to geocode', () => { + // 0,0 is the backend's "unset". A pickup that never geocoded is a data + // problem, not a doorstep collection — calling it one would put a + // phantom violet pin on the map off the coast of Africa. + const unset = classifyBooking(booking({ id: 9, from: { lat: 0, lng: 0 }, to: DOOR_A }), anchors); + expect(unset.flow).toBe(FLOW.FORWARD); + + const missing = classifyBooking({ bookingid: 10, deliverylatitude: DOOR_A.lat, deliverylongitude: DOOR_A.lng }, anchors); + expect(missing.flow).toBe(FLOW.FORWARD); + }); + + it('should not crash on a null or empty booking', () => { + expect(classifyBooking(null, anchors).flow).toBe(FLOW.FORWARD); + expect(classifyBooking({}, anchors).flow).toBe(FLOW.FORWARD); + }); + }); + + describe('classifyBooking — an explicit ordertype wins over geometry', () => { + it('should trust a stored ordertype even when the coordinates disagree', () => { + // Coordinates say Forward; the record says otherwise. Once the backend + // persists the field it is the authority, because it records what the + // operator actually booked. + const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A, ordertype: 'Reverse' }), anchors); + expect(r.flow).toBe(FLOW.REVERSE); + expect(r.derived).toBe(false); + }); + + it('should mark a geometry-decided classification as derived', () => { + expect(classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors).derived).toBe(true); + }); + + it('should fall through to geometry when the stored value means nothing', () => { + const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB, ordertype: 'banana' }), anchors); + expect(r.flow).toBe(FLOW.REVERSE); + expect(r.derived).toBe(true); + }); + }); + + describe('classifyBooking — the pickup point grouping key', () => { + it('should give every order off one facility the SAME key', () => { + const a = classifyBooking(booking({ id: 1, from: AT_KITCHEN, to: DOOR_A }), anchors); + const b = classifyBooking(booking({ id: 2, from: AT_KITCHEN, to: DOOR_B }), anchors); + expect(a.pickupPointKey).toBe(b.pickupPointKey); + }); + + it('should give every customer pickup its OWN key', () => { + // The defect being replaced: these two collapsed onto one map pin + // because both resolved to the tenant's name. + const a = classifyBooking(booking({ id: 1, from: DOOR_A, to: AT_HUB }), anchors); + const b = classifyBooking(booking({ id: 2, from: DOOR_B, to: AT_HUB }), anchors); + expect(a.pickupPointKey).not.toBe(b.pickupPointKey); + }); + + it('should name a facility pickup after the facility and a customer pickup after its address', () => { + const hub = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors); + expect(hub.pickupPointName).toBe('124 Gandhipuram Main Rd'); + + const door = classifyBooking( + booking({ from: DOOR_A, to: AT_HUB, pickupaddress: '22 Ponvizha Nagar' }), + anchors + ); + expect(door.pickupPointName).toBe('22 Ponvizha Nagar'); + }); + }); + + describe('classifyBooking — servicing hub', () => { + it('should prefer an id the booking already carries', () => { + const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B, sourcehubid: 42 }), anchors); + expect(r.servicingHubId).toBe(42); + }); + + it('should fall back to the DESTINATION hub on a customer pickup', () => { + // Nothing at the pickup end names a facility, but an inbound collection + // plainly belongs to the hub it is being brought to. Without this, every + // customer pickup would lose its zone. + const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors); + expect(r.servicingHubId).toBe(7); + }); + + it('should resolve through a tenant location, not only through a Doormile hub', () => { + // The case that was silently broken: the pickup matches the client's own + // kitchen, which is not itself a hub. Before the kitchen inherited a + // servicing hub this returned null and the order reached the backend + // with no zone. + const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors); + expect(r.originAnchor.kind).toBe('tenantlocation'); + expect(r.servicingHubId).toBe(7); + }); + + it('should be null on a P2P run where neither end is a facility', () => { + // Deliberate: a fabricated hub id here would file the order under a zone + // it has nothing to do with. Null lets ZoneContext fall back to radius. + expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors).servicingHubId).toBeNull(); + }); + }); + + describe('normalizeFlow', () => { + it.each([ + ['reverse', FLOW.REVERSE], + ['REVERSE', FLOW.REVERSE], + ['customer_pickup', FLOW.REVERSE], + ['customer pickup', FLOW.REVERSE], + ['customer-pickup', FLOW.REVERSE], + ['hub_pickup', FLOW.FORWARD], + ['outbound', FLOW.FORWARD], + ['p2p', FLOW.P2P], + ['point_to_point', FLOW.P2P], + ['hyperlocal', FLOW.P2P], + ['line_haul', FLOW.LINEHAUL] + ])('should read %s as %s', (input, expected) => { + expect(normalizeFlow(input)).toBe(expected); + }); + + it('should return null for anything it does not recognise, never throw', () => { + [undefined, null, '', ' ', 'banana', 42, {}].forEach((value) => { + expect(normalizeFlow(value)).toBeNull(); + }); + }); + }); + + describe('flow predicates', () => { + it('should identify which flows require travelling out to collect', () => { + expect(hasCustomerPickup(FLOW.REVERSE)).toBe(true); + expect(hasCustomerPickup(FLOW.P2P)).toBe(true); + expect(hasCustomerPickup(FLOW.FORWARD)).toBe(false); + expect(hasCustomerPickup(FLOW.LINEHAUL)).toBe(false); + }); + + it('should identify which flows end at a facility', () => { + expect(hasHubDrop(FLOW.REVERSE)).toBe(true); + expect(hasHubDrop(FLOW.LINEHAUL)).toBe(true); + expect(hasHubDrop(FLOW.FORWARD)).toBe(false); + expect(hasHubDrop(FLOW.P2P)).toBe(false); + }); + + it('should label every flow, and fall back rather than render blank', () => { + Object.values(FLOW).forEach((f) => expect(flowLabel(f)).toBeTruthy()); + expect(flowLabel(FLOW.REVERSE)).toBe('Customer Pickup'); + expect(flowLabel('nonsense')).toBe(flowLabel(FLOW.FORWARD)); + }); + }); + + describe('flowForDraft — what the create form books', () => { + it('should book a hub pickup to a typed address as Forward', () => { + const r = flowForDraft({ + pickupSource: PICKUP_SOURCE.HUB, + dropLat: DOOR_A.lat, + dropLng: DOOR_A.lng, + anchors + }); + expect(r.flow).toBe(FLOW.FORWARD); + }); + + it('should recognise a drop that lands on a hub as a return, not a delivery', () => { + // Without measuring the drop end, a rider bringing a parcel back to the + // hub would be booked P2P and routed as if the hub were a house. + const r = flowForDraft({ + pickupSource: PICKUP_SOURCE.CUSTOMER, + dropLat: HUB.latitude, + dropLng: HUB.longitude, + anchors + }); + expect(r.flow).toBe(FLOW.REVERSE); + expect(r.destAnchor?.hubid).toBe(7); + }); + + it('should book a customer pickup to a customer address as P2P', () => { + const r = flowForDraft({ + pickupSource: PICKUP_SOURCE.CUSTOMER, + dropLat: DOOR_B.lat, + dropLng: DOOR_B.lng, + anchors + }); + expect(r.flow).toBe(FLOW.P2P); + }); + + it('should default to a hub pickup when the form has told it nothing', () => { + expect(flowForDraft().flow).toBe(FLOW.FORWARD); + }); + }); + + describe('buildFlowFields — what goes on the wire', () => { + it('should always name the flow', () => { + expect(buildFlowFields({ flow: FLOW.REVERSE }).ordertype).toBe(FLOW.REVERSE); + }); + + it('should write BOTH hub id fields, which the console has only ever read', () => { + // queries.js has always read b.hubid / b.sourcehubid and the console has + // never written either, which is why zone attribution had to fall back to + // matching hub names against concatenated address text. + const fields = buildFlowFields({ flow: FLOW.FORWARD, servicingHubId: 7, tenantLocationId: 31 }); + expect(fields.sourcehubid).toBe(7); + expect(fields.hubid).toBe(7); + expect(fields.tenantlocationid).toBe(31); + }); + + it('should omit ids it was not given rather than send null', () => { + // pickup_source_type is NOT an id and is always present — see the next + // block for why an omission would say the wrong thing. + expect(buildFlowFields({ flow: FLOW.P2P })).toEqual({ + ordertype: FLOW.P2P, + pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER + }); + expect(buildFlowFields({ flow: FLOW.P2P, servicingHubId: '' })).toEqual({ + ordertype: FLOW.P2P, + pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER + }); + }); + + it('should fall back to Forward rather than put an invented type on the wire', () => { + expect(buildFlowFields({ flow: 'banana' }).ordertype).toBe(FLOW.FORWARD); + expect(buildFlowFields().ordertype).toBe(FLOW.FORWARD); + }); + }); + + describe('pickup_source_type — what kind of place the parcel leaves', () => { + const baseAnchor = anchors.find((a) => a.kind === 'hub'); + const siteAnchor = anchors.find((a) => a.kind === 'tenantlocation'); + + it('should name a base pickup and carry the base id', () => { + const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: baseAnchor }); + expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB); + expect(fields.pickuphubid).toBe(HUB.hubid); + }); + + it("should call a client's own site a merchant, not a base", () => { + // Both are facilities to the geometry, but a rider arriving at a kitchen + // is not arriving at a base — different signage, different person to ask + // for. Collapsing them is what made every logistics pickup read as the + // rider's own base. + const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: siteAnchor }); + expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT); + expect(fields.pickuphubid).toBeUndefined(); + }); + + it('should send "customer" as a value rather than leaving it off', () => { + // An omission reads as "nobody filled this in", which is a different fact + // from "this is somebody's front door". + expect(buildFlowFields({ flow: FLOW.P2P, pickupAnchor: null }).pickup_source_type).toBe( + PICKUP_SOURCE_TYPE.CUSTOMER + ); + }); + + it('should never put an unknown source type on the wire', () => { + const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupSourceType: 'banana', pickupAnchor: baseAnchor }); + expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB); + }); + + it('should not name a base on a pickup that is not from one', () => { + const fields = buildFlowFields({ + flow: FLOW.REVERSE, + pickupSourceType: PICKUP_SOURCE_TYPE.CUSTOMER, + pickupAnchor: baseAnchor + }); + expect(fields.pickuphubid).toBeUndefined(); + }); + + it('should prefer the type stored on the booking over geometry', () => { + // A booking collected at a base whose coordinates were later edited must + // keep reading as a base pickup — the row recorded what actually happened. + const c = classifyBooking( + { + bookingid: 1, + pickup_source_type: PICKUP_SOURCE_TYPE.HUB, + pickuphubid: HUB.hubid, + pickuplatitude: DOOR_A.lat, + pickuplongitude: DOOR_A.lng, + deliverylatitude: DOOR_B.lat, + deliverylongitude: DOOR_B.lng + }, + anchors + ); + expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB); + expect(c.pickupHubId).toBe(HUB.hubid); + expect(c.sourceTypeFromServer).toBe(true); + }); + + it('should classify an older booking from geometry when the row says nothing', () => { + const c = classifyBooking( + { + bookingid: 2, + pickuplatitude: HUB.latitude, + pickuplongitude: HUB.longitude, + deliverylatitude: DOOR_B.lat, + deliverylongitude: DOOR_B.lng + }, + anchors + ); + expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB); + expect(c.sourceTypeFromServer).toBe(false); + }); + }); +}); diff --git a/tests/lib/routingSummary.test.js b/tests/lib/routingSummary.test.js new file mode 100644 index 0000000..6684f52 --- /dev/null +++ b/tests/lib/routingSummary.test.js @@ -0,0 +1,180 @@ +import { + baseLine, + nextActionLabel, + routingReason, + routingRoute, + summariseRouting +} from '@/lib/routingSummary'; + +/** + * The routing block, turned into sentences. + * + * This is what a support agent reads to a rider who has rung asking why his + * screen says hand over at a base instead of deliver to the customer. Two + * things carry the weight here: + * + * 1. A PROJECTION MUST NOT READ AS A DECISION. Before pickup nothing has been + * routed; `decided: false` means the answer is what WILL happen. Stating a + * forecast as a fact is a wrong answer delivered confidently. + * 2. THE WIRE SAYS HUB, PEOPLE SAY BASE. The translation happens at this edge + * and nowhere else, and it never travels back up the wire. + */ + +const HUB_ROUTED = { + pickup_source_type: 'customer', + pickup_source_name: 'Anitha R', + from_address: '12 Race Course Road, Coimbatore', + from_pincode: '641018', + to_address: '44 Mount Road, Chennai', + destination_pincode: '600002', + is_hyperlocal: false, + consignment_state: 'Created', + next_action: 'inward_at_hub', + next_hub: { + id: 1, + name: 'Coimbatore Hub', + address: '14 Avinashi Road, Peelamedu, Coimbatore', + pincode: '641004', + latitude: 11.0272, + longitude: 76.9905 + }, + inwardedat: null, + decided: true +}; + +const HYPERLOCAL = { + ...HUB_ROUTED, + destination_pincode: '641004', + is_hyperlocal: true, + consignment_state: 'Out_for_Delivery', + next_action: 'deliver', + next_hub: null +}; + +describe('routingReason — why it is going that way', () => { + it('should say a base is involved when the areas differ', () => { + expect(routingReason(HUB_ROUTED)).toMatch(/through a base/i); + }); + + it('should say the rider carries it straight there when the areas match', () => { + expect(routingReason(HYPERLOCAL)).toMatch(/straight to the receiver/i); + }); + + it('should never say "hub" to an operator', () => { + // Internal vocabulary. Screens say base. + expect(routingReason(HUB_ROUTED).toLowerCase()).not.toContain('hub'); + }); + + it('should return an empty string rather than throw when there is no routing block', () => { + // An older booking, or a backend that has not shipped this yet. + expect(routingReason(null)).toBe(''); + expect(routingReason(undefined)).toBe(''); + }); +}); + +describe('routingRoute — the from → to line', () => { + it('should show both pincodes', () => { + expect(routingRoute(HUB_ROUTED)).toContain('641018'); + expect(routingRoute(HUB_ROUTED)).toContain('600002'); + }); + + it('should not caveat a decision that has actually been taken', () => { + expect(routingRoute(HUB_ROUTED)).not.toMatch(/not yet collected/i); + }); + + it('should mark an undecided booking as a forecast, not a fact', () => { + const undecided = { ...HUB_ROUTED, decided: false }; + expect(routingRoute(undecided)).toMatch(/not yet collected/i); + expect(routingRoute(undecided)).toMatch(/will happen, not what has/i); + }); + + it('should show a dash for a pincode the booking never carried', () => { + // The delivery-pincode column is genuinely empty on plenty of rows; a blank + // gap in the line reads as a rendering bug rather than as missing data. + expect(routingRoute({ ...HUB_ROUTED, destination_pincode: '' })).toContain('—'); + }); +}); + +describe('nextActionLabel — what the rider does next', () => { + it('should translate every action the backend can send', () => { + expect(nextActionLabel('pickup')).toMatch(/collect/i); + expect(nextActionLabel('inward_at_hub')).toMatch(/base/i); + expect(nextActionLabel('start_delivery')).toMatch(/delivery run/i); + expect(nextActionLabel('deliver')).toMatch(/receiver/i); + expect(nextActionLabel('handed_to_hub')).toMatch(/handed over/i); + expect(nextActionLabel('none')).toMatch(/nothing further/i); + }); + + it('should render inward_at_hub as a BASE handover, never as a hub one', () => { + expect(nextActionLabel('inward_at_hub').toLowerCase()).not.toContain('hub'); + }); + + it('should show an unknown action as itself rather than swallow it', () => { + // The backend may add actions. A blank row tells the operator nothing; the + // raw word at least tells them something new exists. + expect(nextActionLabel('return_to_sender')).toBe('return_to_sender'); + }); + + it('should show a dash when there is no action at all', () => { + expect(nextActionLabel(undefined)).toBe('—'); + expect(nextActionLabel('')).toBe('—'); + }); +}); + +describe('baseLine — naming the base', () => { + it('should read the base with its pincode', () => { + expect(baseLine(HUB_ROUTED.next_hub)).toBe('Coimbatore Hub · 641004'); + }); + + it('should fall back to the name alone when a base has no pincode', () => { + expect(baseLine({ name: 'Coimbatore Hub' })).toBe('Coimbatore Hub'); + }); + + it('should be null when no base is involved', () => { + // A hyperlocal parcel has no base. That is the correct answer, not a gap. + expect(baseLine(null)).toBeNull(); + expect(baseLine({})).toBeNull(); + }); +}); + +describe('summariseRouting — everything the drawer renders', () => { + it('should carry the base, its address and the next step for a hub-routed parcel', () => { + const s = summariseRouting(HUB_ROUTED); + expect(s.base).toBe('Coimbatore Hub · 641004'); + expect(s.baseAddress).toBe('14 Avinashi Road, Peelamedu, Coimbatore'); + expect(s.nextAction).toMatch(/base/i); + expect(s.consignmentState).toBe('Created'); + expect(s.decided).toBe(true); + }); + + it('should carry no base for a hyperlocal parcel', () => { + const s = summariseRouting(HYPERLOCAL); + expect(s.base).toBeNull(); + expect(s.baseAddress).toBeNull(); + expect(s.nextAction).toMatch(/receiver/i); + }); + + it('should surface the received-at time once the parcel is actually inwarded', () => { + const s = summariseRouting({ + ...HUB_ROUTED, + consignment_state: 'Inwarded_at_Hub', + next_action: 'handed_to_hub', + inwardedat: '2026-09-02T14:22:10Z' + }); + expect(s.inwardedAt).toBe('2026-09-02T14:22:10Z'); + expect(s.nextAction).toMatch(/handed over/i); + }); + + it('should show a dash for a parcel that has no consignment yet', () => { + const s = summariseRouting({ ...HUB_ROUTED, consignment_state: '', decided: false }); + expect(s.consignmentState).toBe('—'); + expect(s.decided).toBe(false); + }); + + it('should be null when the booking carries no routing block, so the panel hides', () => { + // An older backend, or a booking fetched before this shipped. The drawer + // renders nothing rather than a panel of dashes. + expect(summariseRouting(null)).toBeNull(); + expect(summariseRouting(undefined)).toBeNull(); + }); +});