updates on the bookings page and updated ai and more thngs
This commit is contained in:
@@ -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 ??
|
||||
|
||||
@@ -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
|
||||
|
||||
54
src/lib/bookingDrops.js
Normal file
54
src/lib/bookingDrops.js
Normal file
@@ -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')}`;
|
||||
};
|
||||
120
src/lib/bulkOrderPayload.js
Normal file
120
src/lib/bulkOrderPayload.js
Normal file
@@ -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 }));
|
||||
110
src/lib/customerAppBookings.js
Normal file
110
src/lib/customerAppBookings.js
Normal file
@@ -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));
|
||||
};
|
||||
@@ -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' });
|
||||
|
||||
|
||||
352
src/lib/geocodingService.js
Normal file
352
src/lib/geocodingService.js
Normal file
@@ -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;
|
||||
}
|
||||
62
src/lib/hubForm.js
Normal file
62
src/lib/hubForm.js
Normal file
@@ -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)
|
||||
}
|
||||
};
|
||||
};
|
||||
567
src/lib/orderFlow.js
Normal file
567
src/lib/orderFlow.js
Normal file
@@ -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 };
|
||||
};
|
||||
78
src/lib/routingSummary.js
Normal file
78
src/lib/routingSummary.js
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user