diff --git a/src/lib/bulkOrderPayload.js b/src/lib/bulkOrderPayload.js index b7ec215..d73dfbf 100644 --- a/src/lib/bulkOrderPayload.js +++ b/src/lib/bulkOrderPayload.js @@ -10,6 +10,7 @@ // 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 { latitude, longitude } from '@/lib/coords'; import { PICKUP_SOURCE, buildFlowFields, flowForDraft } from '@/lib/orderFlow'; export const BULK_SERVICE_OPTION = 'Normal'; @@ -83,15 +84,40 @@ export const buildBulkBookingPayload = ({ pickupaddress: origin.address || '', pickuppincode: String(origin.pincode || origin.postcode || ''), pickupcity: origin.city || '', - pickuplatitude: Number(origin.latitude) || 0, - pickuplongitude: Number(origin.longitude) || 0, + // A coordinate we do not have is sent as null, never as 0. + // + // `Number(x) || 0` turned every missing pin into (0, 0), and the backend + // cannot tell that apart from a deliberate pin in the Gulf of Guinea. For + // the PICKUP end it currently recovers — adminController falls back to the + // hub or tenant location when pickup is 0,0 — but relying on that means + // relying on a rescue for data we should not have sent. + pickuplatitude: latitude(origin.latitude), + pickuplongitude: longitude(origin.longitude), 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, + // The DELIVERY end has no rescue, which is where the money went. With + // delivery at 0,0 the backend skips its distance calculation entirely + // (`if booking.Deliverylatitude != 0 && ...`) and prices the order at base + // rate with no distance component — or, when the console did manage to + // quote a charge from those same 0,0 coordinates, bills an 11,000 km run. + // One defect, opposite errors. + // + // Be clear about what null buys and what it does not. It does NOT make the + // server refuse: adminController declares these as `float64`, not + // `*float64`, and Go unmarshals JSON null into a float64 as 0 with no + // error — verified, not assumed. So by the time a null reaches the handler + // it is indistinguishable from the 0 this used to send. + // + // The submission guard in MultipleOrders is therefore the actual fix; this + // is defence in depth and an honest local shape. It stops the console + // itself from treating a missing pin as a position — the distance + // calculation and the zone filters read these values too — and it means a + // future `*float64` on the server side starts working with no change here. + deliverylatitude: latitude(row?.latitude), + deliverylongitude: longitude(row?.longitude), deliverytime, service_option: BULK_SERVICE_OPTION, finalprice: Number(row?.totalcharge) || 0, diff --git a/src/lib/coords.js b/src/lib/coords.js new file mode 100644 index 0000000..db52350 --- /dev/null +++ b/src/lib/coords.js @@ -0,0 +1,116 @@ +// ==============================|| Coordinates ||============================== // +// +// One place that decides whether a latitude/longitude is real. +// +// ── Why this file exists ── +// +// The console validated coordinates with `Number.isFinite(Number(value))` in +// about twenty places, and that guard does not do what it looks like it does: +// +// Number(null) === 0 → finite → passes +// Number('') === 0 → finite → passes +// Number(false) === 0 → finite → passes +// +// A missing coordinate therefore became latitude 0, longitude 0 — a point in +// the Gulf of Guinea — and every downstream consumer treated it as a real +// place. The cost was not a blank field, it was a wrong price: OSRM refuses to +// route to open ocean, `calculateDrivingRoute` falls back to Haversine, and a +// Coimbatore pickup measured against 0,0 comes out at roughly 11,000 km. That +// distance reaches `calculateTotalCharge`, becomes the row's `finalprice`, and +// the backend honours it verbatim (`adminController.go`: `if req.Finalprice > 0`). +// The same defect bills the opposite way when the console fails to quote at +// all: the backend then measures for itself, skips the calculation because +// delivery is 0,0, and charges base price with no distance component. +// +// So a zero coordinate is rejected here rather than merely reported. On Earth +// (0, 0) is a valid point; in this product it is only ever the residue of a +// failed geocode, and treating it as data has never once been correct. +// +// Range is checked too, because nothing in the console checked it before. It +// is worth being precise about what that does and does not buy, since the +// axes genuinely do swap inside this codebase — OSRM takes lon,lat while +// Leaflet takes lat,lng — so a transposed call site is a live risk: +// +// Caught: a longitude past 90 read as a latitude. Dibrugarh (27.4728, +// 94.9120) transposed gives latitude 94.9, past the pole. +// NOT caught: anything west of 90°E. Coimbatore is (11.0168, 76.9558), and +// 76.9558 is a legal latitude — northern Siberia — so a +// transposed Coimbatore pin satisfies every rule here. +// +// Detecting that second case needs a plausibility check against the operating +// region rather than a bounds check; `geocodingService.isWithinIndia` is the +// right tool and is deliberately not folded in here, because this module is +// about whether a number is a coordinate at all, not about where we operate. +// +// The logic is lifted from `hubForm.js`, which has always got this right. It +// was correct in one form and nowhere else; now it is correct in one place. + +/** Latitude is bounded at the poles, longitude at the antimeridian. */ +const BOUNDS = Object.freeze({ lat: 90, lng: 180 }); + +/** + * One coordinate, or null when there isn't one. + * + * @param {unknown} value raw cell, form field, or API field + * @param {'lat'|'lng'} axis which bound to apply + * @returns {number|null} a usable coordinate, or null + */ +export const coord = (value, axis) => { + if (value === null || value === undefined || value === '') return null; + // A boolean coerces to 0/1 and an empty array to 0. Neither is a coordinate, + // and both reach here from spreadsheet cells. + if (typeof value === 'boolean') return null; + + const n = Number(value); + if (!Number.isFinite(n)) return null; + if (n === 0) return null; + + const max = BOUNDS[axis]; + if (max === undefined) return null; + return n < -max || n > max ? null : n; +}; + +export const latitude = (value) => coord(value, 'lat'); +export const longitude = (value) => coord(value, 'lng'); + +/** + * True when both halves of a pair are usable. + * + * Both or neither: a point with one axis is not half a location, it is no + * location, and passing it on lets the missing half default to 0 somewhere + * further downstream — which is the bug this module exists to end. + */ +export const hasCoords = (lat, lng) => + coord(lat, 'lat') !== null && coord(lng, 'lng') !== null; + +/** + * A `{ latitude, longitude }` pair, or null. + * + * Reads the shapes this codebase actually passes around — `{latitude,longitude}` + * from the API and the forms, `{lat,lng}` from Leaflet and the map pickers — + * so callers stop re-deriving which spelling they were handed. + */ +export const coordsOf = (source) => { + if (!source || typeof source !== 'object') return null; + const lat = coord(source.latitude ?? source.lat, 'lat'); + const lng = coord(source.longitude ?? source.lng, 'lng'); + if (lat === null || lng === null) return null; + return { latitude: lat, longitude: lng }; +}; + +/** + * The message shown when a coordinate is missing or wrong, or null when it is + * fine. Kept next to the rule so the form text and the validation cannot drift. + */ +export const coordError = (value, axis) => { + const axisName = axis === 'lat' ? 'Latitude' : 'Longitude'; + if (value === null || value === undefined || value === '') { + return 'Navigate cannot work without a pin'; + } + const n = Number(value); + if (typeof value === 'boolean' || !Number.isFinite(n)) return 'Enter a number'; + if (n === 0) return 'Navigate cannot work without a pin'; + const max = BOUNDS[axis]; + if (n < -max || n > max) return `${axisName} must be between ${-max} and ${max}`; + return null; +}; diff --git a/src/lib/distance.js b/src/lib/distance.js index f2cc58d..0058db3 100644 --- a/src/lib/distance.js +++ b/src/lib/distance.js @@ -4,6 +4,8 @@ * and falls back seamlessly to Haversine with a 1.3 road winding multiplier. */ +import { coordsOf } from '@/lib/coords'; + let lastRouteDurationMin = null; export const getLastRouteDurationMin = () => lastRouteDurationMin; @@ -15,15 +17,27 @@ export const getLastRouteDurationMin = () => lastRouteDurationMin; * @returns {Promise<{ distance: number, minutes: number, polyline: Array<[number, number]>, resolved: boolean }>} */ export const calculateDrivingRoute = async (origin, destination) => { - const lat1 = Number(origin?.latitude); - const lon1 = Number(origin?.longitude); - const lat2 = Number(destination?.latitude); - const lon2 = Number(destination?.longitude); - - if (!Number.isFinite(lat1) || !Number.isFinite(lon1) || !Number.isFinite(lat2) || !Number.isFinite(lon2)) { + // Validated through lib/coords rather than with Number.isFinite. + // + // The previous guard was `Number.isFinite(Number(value))`, which accepts + // null, '' and false — all three coerce to 0, and 0 is finite. A missing pin + // therefore reached OSRM as (0, 0). OSRM answers HTTP 200 with no `routes` + // for a point in open ocean, so control fell through to the Haversine + // fallback below and a Coimbatore pickup measured against 0,0 returned + // roughly 11,000 km. That number becomes the row's charge and the backend + // honours it verbatim, so the guard being wrong here was a billing defect, + // not a display one. + const from = coordsOf(origin); + const to = coordsOf(destination); + if (!from || !to) { throw new Error("Invalid coordinates"); } + const lat1 = from.latitude; + const lon1 = from.longitude; + const lat2 = to.latitude; + const lon2 = to.longitude; + // 1. Try OSRM API with full GeoJSON geometry for live polyline map preview const osrmBaseUrl = (typeof import.meta !== 'undefined' && import.meta?.env?.VITE_OSRM_URL) || diff --git a/src/lib/geocodingService.js b/src/lib/geocodingService.js index 33f21a6..50024fb 100644 --- a/src/lib/geocodingService.js +++ b/src/lib/geocodingService.js @@ -145,10 +145,25 @@ export const standardizePlace = ({ longitude: Number.isFinite(lngNum) ? lngNum : null, provider, raw, + // Google-Maps-shaped accessors, for the call sites that still read a place + // the way the Places API returned one. + // + // These return null — not 0 — when there is no coordinate, and that is a + // fix rather than a style choice. Returning 0 made this object contradict + // itself: `.latitude` above says null while `.geometry.location.lat()` + // said 0, so which answer a caller got depended on which spelling it + // happened to read. MultipleOrders read the accessor, so a suggestion with + // no coordinates became a drop pinned at (0, 0); the pre-submit guard + // tested `== null` and waved it through; the distance calculation then + // routed Coimbatore to the Gulf of Guinea, fell back to Haversine, and + // billed roughly 11,000 km — a number the backend honours verbatim + // because the console sent it as `finalprice`. + // + // null propagates as "no pin", which every guard already handles. geometry: { location: { - lat: () => (Number.isFinite(latNum) ? latNum : 0), - lng: () => (Number.isFinite(lngNum) ? lngNum : 0) + lat: () => (Number.isFinite(latNum) && latNum !== 0 ? latNum : null), + lng: () => (Number.isFinite(lngNum) && lngNum !== 0 ? lngNum : null) } }, address_components: [ diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx index 99d745a..07d15cd 100644 --- a/src/pages/doormile/orders/CreateOrder.jsx +++ b/src/pages/doormile/orders/CreateOrder.jsx @@ -32,7 +32,22 @@ import { getAdminTenants, getTenantLocations } from '@/api/doormile/endpoints'; -import { calculateDrivingRoute, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance'; +// calculateDrivingDistance was used by the deadhead-leg effect below without +// ever being imported — a ReferenceError on every customer-pickup order that +// had a pinned collection address, thrown synchronously inside the effect so +// the `.catch()` on that promise chain could never see it. Neither the build +// nor the lint config catches an unbound identifier in a .jsx file, which is +// why it survived. Found while auditing the coordinate changes; unrelated to +// them. +// +// getLastRouteDurationMin is gone from this list: it was imported and never +// called, which the project's own lint reports as an error. Dropped while +// rewriting the statement rather than left behind in it. +import { + calculateDrivingDistance, + calculateDrivingRoute, + calculateTotalCharge +} from '@/lib/distance'; import { saveRecentAddress } from '@/lib/geocodingService'; import { useHubs } from '@/lib/doormileHooks'; import { diff --git a/src/pages/doormile/orders/MultipleOrders.jsx b/src/pages/doormile/orders/MultipleOrders.jsx index a906785..5cd79c5 100644 --- a/src/pages/doormile/orders/MultipleOrders.jsx +++ b/src/pages/doormile/orders/MultipleOrders.jsx @@ -37,6 +37,7 @@ import { normalizeHeader, requiredSheetColumns } from '@/lib/bulkOrderColumns'; +import { coord, hasCoords } from '@/lib/coords'; import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance'; import { useHubs } from '@/lib/doormileHooks'; import { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow'; @@ -465,8 +466,20 @@ export default function MultipleOrders() { }); }); - const latitude = place.geometry.location.lat(); - const longitude = place.geometry.location.lng(); + // Read the place's own fields first and fall back to the Google-shaped + // accessors, matching how line 343 already does it. Reading the accessor + // alone was the entry point for the 0,0 defect: geocodingService used to + // return 0 from `geometry.location.lat()` for a suggestion with no + // coordinates, this assignment stored that 0 verbatim, and the pre-submit + // guard's `== null` test could not see it. + const latitude = coord( + place?.latitude ?? place?.geometry?.location?.lat?.(), + 'lat' + ); + const longitude = coord( + place?.longitude ?? place?.geometry?.location?.lng?.(), + 'lng' + ); const address = place.formatted_address || ''; setDropCust((prev) => @@ -477,6 +490,20 @@ export default function MultipleOrders() { ) ); + // No pin, no quote. Pricing an unpinned row is what produced the wrong + // charges: the distance came back from a fallback measurement to 0,0 and + // the row carried it as `totalcharge`, which the backend then honours as + // `finalprice`. Clearing the charge leaves the row visibly incomplete, and + // the submit guard below refuses it. + if (latitude === null || longitude === null) { + setDropCust((prev) => + prev.map((c) => + c._rowkey === rowkey ? { ...c, distance: null, totalcharge: null } : c + ) + ); + return; + } + try { const { roundedDistance, totalcharge } = await calculateDistance({ latitude, longitude }); setDropCust((prev) => @@ -532,7 +559,17 @@ export default function MultipleOrders() { return; } - const incomplete = dropCust.find((c) => c.latitude == null || c.longitude == null || !c.address); + // Checked with hasCoords, not `== null`. + // + // `== null` matches null and undefined only, so a row carrying latitude 0 + // — which is what a suggestion with no coordinates used to produce — + // passed this guard and was submitted. It then priced either as an + // 11,000 km run or, with no charge quoted, at base rate with no distance + // component. hasCoords also rejects '' and unparseable sheet cells, which + // reach here from an uploaded file. + const incomplete = dropCust.find( + (c) => !hasCoords(c.latitude, c.longitude) || !c.address + ); if (incomplete) { OpenToast( `Please search and complete address for ${incomplete.firstname || 'one of the drops'}`, @@ -542,6 +579,31 @@ export default function MultipleOrders() { return; } + // The collection end, which was never checked here at all. CreateOrder + // gained this check already; the bulk form did not, so a hand-typed + // pickup that failed to geocode submitted every row in the file against a + // pickup the backend has to rescue from the hub record. + // + // Checked per row through rowPickup, not once against the shared pickup: + // a row that resolved its own sender address collects from THAT, and + // buildBulkBookingPayload reads the same `row.__pickup || sharedPickup`. + // Validating only the shared one would leave a per-row collection point + // unchecked, which is the half of the fan-out that varies. + const unpinnedPickup = dropCust.find((c) => { + const origin = rowPickup(c); + return !hasCoords(origin?.latitude, origin?.longitude); + }); + if (unpinnedPickup) { + OpenToast( + rowPickup(unpinnedPickup) === pickCust + ? 'The collection address has no coordinates — pick it from the suggestions or drop a map pin' + : `The sender address for ${unpinnedPickup.firstname || 'one of the drops'} has no coordinates`, + 'warning', + 4000 + ); + return; + } + const deliverytime = dayjs(`${startDate} ${selectedSlotTime}`, 'YYYY-MM-DD HH:mm').format( 'YYYY-MM-DD HH:mm:ss' ); @@ -1048,7 +1110,14 @@ export default function MultipleOrders() {
{dropCust.map((row, idx) => { - const needsAddress = row.latitude == null || row.longitude == null || !row.address; + // Same rule as the submit guard, deliberately. When this + // used `== null` and the guard used hasCoords, a row + // carrying latitude 0 rendered as complete and was then + // refused on dispatch — an error with nothing on screen + // pointing at the row that caused it. Both ends ask the + // same question now, so an unusable row always shows its + // address search. + const needsAddress = !hasCoords(row.latitude, row.longitude) || !row.address; return (