updates on the bookings page and updated ai and more thngs

This commit is contained in:
2026-09-11 11:20:38 +05:30
parent 1caa7b71a9
commit 49ee0c5076
40 changed files with 5796 additions and 359 deletions

View File

@@ -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 ??

View File

@@ -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
View 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
View 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 }));

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

View File

@@ -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
View 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
View 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
View 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
View 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
};
};