updates on the changes on warning and lat and long fixed to make it good

This commit is contained in:
2026-09-18 11:59:43 +05:30
parent bc79ba4912
commit 05f08efc64
10 changed files with 669 additions and 28 deletions

View File

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

116
src/lib/coords.js Normal file
View File

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

View File

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

View File

@@ -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: [

View File

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

View File

@@ -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() {
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{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 (
<tr key={row._rowkey || idx} className="hover:bg-slate-50/70 transition-colors">
<td className="py-3 pl-4 pr-2 text-slate-600 font-mono text-xs font-bold">{idx + 1}</td>

View File

@@ -6,6 +6,8 @@
* @param {Object} destination - { latitude, longitude }
* @returns {Promise<number>} - Round distance in KM
*/
import { coordsOf } from '@/lib/coords';
// Duration from the most recent OSRM route, in minutes, or null when the
// Haversine fallback was used (a straight-line estimate has no honest
// duration — better to show nothing than a made-up ETA).
@@ -14,15 +16,25 @@ let lastRouteDurationMin = null;
export const getLastRouteDurationMin = () => lastRouteDurationMin;
export const calculateDrivingDistance = async (origin, destination) => {
const lat1 = origin?.latitude;
const lon1 = origin?.longitude;
const lat2 = destination?.latitude;
const lon2 = destination?.longitude;
if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) {
// Validated through lib/coords rather than with `== null`.
//
// `== null` matches null and undefined only, so 0, '' and a garbage sheet
// cell all passed and reached OSRM as (0, 0). OSRM has no route to open
// ocean, so control fell through to the Haversine fallback and a Coimbatore
// pickup came back at roughly 11,000 km — which becomes the row's charge and
// is honoured verbatim by the backend. Same defect as lib/distance.js; this
// is the second live copy of this module (both trees have importers).
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 (either self-hosted or public demo server for fallback)
const osrmBaseUrl = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_OSRM_URL) || (typeof process !== 'undefined' && process.env?.REACT_APP_OSRM_URL) || "https://router.project-osrm.org";
try {