updates on the fix

This commit is contained in:
2026-08-12 12:55:18 +05:30
parent bf28249528
commit fabb74c326
15 changed files with 1478 additions and 528 deletions

240
doormile-flow.md Normal file
View File

@@ -0,0 +1,240 @@
# Doormile — order to delivery
Every endpoint in the DailyGrubs path, the payload that goes in, and what
actually changes when it does. Base URL `https://api.doormile.com/api/v1`.
Payload shapes are taken from the request structs in this repo, not from
documentation — where the two disagree, the code is right. Where a field is
optional it says so.
---
## Auth
Two separate logins.
**Console.** The token carries `tenantid`, which is what scopes a client to
their own data. A client passing another tenant's `?tenantid=` gets 403; reading
another tenant's resource by id gets 404, so ids aren't probeable.
```
POST /admin/login
{ "email": "info@dailygrubs.com", "password": "admin" }
→ { "token": "eyJ…", "user": { "id": 43, "tenantid": 13 } }
```
**Rider.** Two calls, and `configid` must be **1001** on both — a Doormile login
partition with no jupiter equivalent. Omitting it is the most common reason a
rider looks like they don't exist.
```
POST /miler/login
{ "phone": "9787698259", "configid": 1001 }
POST /miler/verify-pin
{ "phone": "9787698259", "pin": "1234", "configid": 1001, "device_token": "fcm-…" }
→ { "token": "eyJ…" }
```
`POST /miler/reset-pin` is **admin-only** despite sitting under `/miler`. A phone
number is the login identifier, not a secret — left open, reset-then-verify takes
over any rider account in two calls. The rider app must not call it.
---
## 1. Create a booking
```
POST /admin/expressbooking
{
"tenantid": 13,
"tenantlocationid": 20, // the kitchen. optional — inferred if omitted
"customer_phone": "9876500011",
"customer_name": "Priya R",
"pickupaddress": "DailyGrubs RS Puram Kitchen",
"pickuppincode": "641002",
"pickuplatitude": 11.004500, "pickuplongitude": 76.961200,
"deliveryaddress": "12 Bharathi Rd, Peelamedu",
"deliverypincode": "641004",
"deliverylatitude": 11.051000, "deliverylongitude": 76.930000,
"service_option": "Fast",
"finalprice": 65,
"parcels": [ { "itemcategory": "Food", "weight": 1.2 } ]
}
→ { "bookingid": 118, "bookingno": "DM-BK-…", "status": "Created" }
```
Writes a `pickupbookings` row plus its parcels and price.
**On `tenantlocationid`.** This is the client's own site — the kitchen. Omit it
and it's resolved from the pickup coordinates: nearest stored site within 150m,
falling back to an address match, nil when unsure. It is what per-kitchen
reporting groups by.
`pickuplocationid` is accepted only as a legacy alias and is never stored as
given — the column of that name foreign-keys to `appcustomerlocations` (a B2C
customer's saved address), so writing a client site id into it fails the insert.
**CityGate.** The pickup pincode prefix must be an open city: `641` Coimbatore,
`600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil. Anything else
is refused at creation. jupiter had no such gate.
## 2. Or create them in bulk
```
POST /admin/expressbooking/bulk
{ "bookings": [ { …same shape… }, { … } ] } // max 200
→ { "results": [
{ "index": 0, "success": true, "bookingid": 119, "bookingno": "DM-BK-…" },
{ "index": 1, "success": false, "error": "pickup pincode not serviceable" }
] }
```
Per-row results, never all-or-nothing — one bad address doesn't lose the other
199. Each booking is its own transaction. A client login cannot use bulk to
smuggle in another tenant's id; `tenantid` is pinned to the caller's own.
## 3. A rider is found — automatic
No call needed. Creation publishes `booking.assignment_requested` to JetStream
after the transaction commits. A worker searches riders within 10km via Redis
GEO, scores them through the AI layer, and commits the assignment. If nobody is
available it retries **5 times, 2 minutes apart**, then publishes
`booking.assignment_failed` for the dispatch agent.
The retry state lives in NATS, not in process memory, so a pod restart no longer
loses a booking mid-wait.
To assign by hand instead:
```
POST /admin/bookings/:id/assign-miler
{ "mileruserid": 38 }
```
## 4. Clear a hub queue, and order the stops
```
POST /hub/bookings/batch-assign
{ "bookingids": [118, 119, 120], "max_per_rider": 5 }
→ { "assigned": 3, "skipped": 0, "riderssequenced": 1,
"results": [ { "bookingid": 118, "assigned": true,
"mileruserid": 38, "distance_km": 1.4 } ] }
```
**This is the only place stops get ordered.** After assigning, each affected
rider's whole active set is sent to the Route Optimization API
(`POST /api/v1/optimization/doormile/sequence` on `routes.workolik.com`), which
returns a road-network sequence via Valhalla — not straight-line distance. The
step, per-leg distance, cumulative distance and ETA are written onto
`bookingassignments`.
**Assignment picks _who_; sequencing picks _what order_.** They are separate,
and sequencing can only run once a rider is known — which is why bulk *creation*
cannot sequence anything, however many bookings you send. jupiter got away with
sequencing inside `createdeliveries` because that payload was already one
rider's run; Doormile's bulk endpoint is up to 200 bookings across many riders.
Sequencing is best-effort and runs after the assignments commit: the optimizer
is a separate service over the network, and it being down must leave bookings
assigned but unordered, never undo the batch.
## 5. The rider runs the route
```
GET /miler/assignments
→ [ { "bookingassignmentid": 555, "bookingid": 120,
"step": 1, "previouskms": 4.0, "cumulativekms": 4.0,
"etaminutes": 14, "cumulativeeta": 14 }, … ]
```
Returned in **step order**. `step: 0` means *not sequenced* — never *first* — and
sorts to the end. A rider with one stop is never sequenced, so 0 is common.
Then, per booking:
```
POST /miler/assignments/:id/accept
POST /miler/bookings/:bookingid/reached
POST /miler/bookings/:bookingid/parcel
{ "parcels": [ { "weight": 1.4, "length": 20, "width": 15, "height": 10 } ] }
POST /miler/bookings/:bookingid/payment
{ "amount": 65, "paymentmode": "Cash", "transactionref": "" }
POST /miler/bookings/:bookingid/pickup-complete
```
`pickup-complete` is the pivot the old system had no concept of. It converts the
booking into a **consignment**, recomputes chargeable weight from the dimensions
the rider actually measured, carries the kitchen across, and decides routing —
matching 3-digit pincode prefixes go straight to `Out_for_Delivery` (hyperlocal),
everything else routes via a hub.
Other rider actions: `vehicle-required` (needs a bigger vehicle), `cancel`
(before pickup).
## 6. Deliver
```
POST /miler/consignments/:id/deliver
{ "deliveredtoname": "Priya R",
"photourl": "https://…", "receiversignatureurl": "https://…",
"lat": 11.051, "lon": 76.93,
"otp": "418317" } // only when the tenant requires it
```
Marks the consignment delivered and writes a history row. **Delivery OTP is
opt-in per tenant** (`Tenant.Requiredeliveryotp`, default off — off for
DailyGrubs). When on it is verified server-side and never serialised outward:
returning it would hand the rider the code they are meant to be told.
Couldn't deliver? `POST /miler/consignments/:id/skip` increments `attemptcount`
rather than failing the parcel.
---
## Watching it
| What | Endpoint |
|---|---|
| One booking end to end | `GET /admin/bookings/:id/track` |
| A parcel's GPS trail + history | `GET /admin/consignments/:id/logs` |
| Riders today | `GET /admin/milers/summary?from=&to=` |
| One rider's logs | `GET /admin/milers/:id/logs` |
| Per kitchen | `GET /admin/locations/summary?tenantid=&locationid=` |
| Reports | `GET /admin/reports?from=&to=&tenantid=&locationid=&hubid=` |
Admin miler endpoints key on **`milerprofileid`**, while `assign-miler` takes a
**`mileruserid`** in the body — different identity spaces on adjacent endpoints.
Worth checking which one you have.
---
## State
| Capability | State |
|---|---|
| Booking create, bulk, tracking, reports | deployed |
| Tenant scoping for client logins | deployed |
| Per-kitchen attribution | deployed |
| Durable assignment retry (JetStream) | built, **not deployed** |
| Stop sequencing (Doormile side) | built, **not deployed** |
| `/optimization/doormile/sequence` (routes.workolik.com) | built, **not deployed** |
| Multi-stop optimizer service itself | live |
**Sequencing needs two deploys, not one** — the endpoint in the route-optimizer
service (docker-compose on `31.97.228.132`, behind Traefik) and Doormile's
client that calls it. Ship one without the other and sequencing fails quietly:
bookings stay assigned but unordered, and riders choose their own order.
Nothing on the client side has moved. The rider app and express console still
call `jupiter.nearle.app`. These endpoints exist and are tested; no production
traffic uses them yet.

View File

@@ -27,7 +27,12 @@ const LocationAutocomplete = forwardRef(
},
ref
) => {
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem('applocations') || '[]'));
// Tenant-scoped cache key — fetchAppLocations() now narrows the hub list
// to the logged-in tenant's own city, so caching under one shared flat
// key would leak a previous tenant's list into a different tenant's
// session on the same browser if it ever skipped a full logout clear.
const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`;
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]'));
useEffect(() => {
// Zones are derived from GET /admin/hubs (the new API has no dedicated
@@ -35,7 +40,7 @@ const LocationAutocomplete = forwardRef(
const fetchLocations = async () => {
try {
const updatedLocations = await fetchAppLocations();
localStorage.setItem('applocations', JSON.stringify(updatedLocations));
localStorage.setItem(applocationsCacheKey, JSON.stringify(updatedLocations));
setLocations(updatedLocations);
} catch (err) {
console.error('Error fetching locations in LocationAutocomplete:', err);
@@ -45,7 +50,7 @@ const LocationAutocomplete = forwardRef(
if (locations.length === 0) {
fetchLocations();
}
}, [locations.length]);
}, [locations.length, applocationsCacheKey]);
// Helpers (only used by pill variant) — match the deliveries page's
// token shorthand so the same opacity ramp is applied here.

View File

@@ -38,15 +38,33 @@ import {
// lifecycle keys (pending/accepted/arrived/picked/active/skipped/delivered/
// cancelled) for its tabs, chip counts, and row badges. The new booking
// status enum uses different strings entirely (Pending_Pickup,
// Converted_To_Consignment, Out_for_Delivery, ...) — only Pending_Pickup,
// Converted_To_Consignment (from a real sample) and Out_for_Delivery (named
// in express-console-api.md's CityGate note) are confirmed; the rest of this
// mapping is a best-effort guess. Unmapped statuses pass through lowercased,
// which the page's own fallback renders as an "unknown" badge rather than
// crashing. There's no confirmed equivalent for arrived/picked/skipped at
// all, so those tabs will show a 0 count until the real enum is confirmed.
// Miler_Assigned, Pickup_Scheduled, Converted_To_Consignment, Out_for_Delivery,
// ...) — Pending_Pickup, Converted_To_Consignment, Out_for_Delivery (named in
// express-console-api.md's CityGate note), and now Miler_Assigned /
// Pickup_Scheduled (confirmed live via a real booking-status breakdown logged
// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS
// comment) are confirmed; the rest of this mapping is still a best-effort
// guess. Unmapped statuses pass through lowercased, which the page's own
// fallback renders as an "unknown" badge rather than crashing. There's no
// confirmed equivalent for arrived/picked/skipped at all, so those tabs will
// show a 0 count until the real enum is confirmed.
//
// Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'.
// Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler
// from this console); "Accepted" is meant to reflect the RIDER's own action
// (doormile-flow.md's POST /miler/assignments/:id/accept). Conflating the two
// made a just-assigned, not-yet-acknowledged order look already-accepted —
// wrong from an ops standpoint (per explicit product requirement: stays
// "Pending" in the operator's eyes until the rider actually accepts).
// Pickup_Scheduled — the status that appears once a rider has accepted and
// the pickup is on their route — is the best available proxy for "rider
// accepted" in the currently-confirmed enum, so that one maps to 'accepted'.
// If the backend turns out to have a distinct status specifically for the
// accept action, add it here rather than reusing Miler_Assigned for it.
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
pending_pickup: 'pending',
miler_assigned: 'pending',
pickup_scheduled: 'accepted',
converted_to_consignment: 'accepted',
out_for_delivery: 'active',
delivered: 'delivered',
@@ -101,6 +119,19 @@ export const getRiderPeriodicLogs = async (userid) => {
// ==============================|| fetchAppLocations (zone/location picker) ||============================== //
// The new API has no "zones" resource — applocationid only exists as a field
// on Hubs. Derive a zone picker list from the distinct cities in GET /admin/hubs.
//
// Scoped per tenant: Hub has no tenantid field at all (confirmed against
// express-console-api.md), so GET /admin/hubs always returns every hub
// nationwide with no server-side tenant filtering possible. A client-tenant
// login should only see their own city's hub(s) — inferred from the
// tenant's own GET /admin/tenants/:id/locations (city is free text there,
// and on the hub side too, so matched via normalized exact-then-substring
// comparison, not a clean foreign key). Staff logins (tenantid falsy) keep
// seeing every hub, unchanged. Fails open (full list) whenever the tenant's
// city can't be determined or nothing matches, rather than ever locking an
// operator out with an empty picker.
const normCity = (s) => String(s || '').trim().toLowerCase();
export const fetchAppLocations = async () => {
try {
const hubs = await getHubs();
@@ -110,7 +141,36 @@ export const fetchAppLocations = async () => {
seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname });
}
});
return [...seen.values(), { locationname: 'All', applocationid: 0 }];
const allLocations = [...seen.values()];
const tenantid = localStorage.getItem('tenantid');
const isStaff = !tenantid || tenantid === '0';
if (isStaff) {
return [...allLocations, { locationname: 'All', applocationid: 0 }];
}
const tenantLocations = await gettenantlocations(tenantid);
const tenantCities = new Set((tenantLocations || []).map((loc) => normCity(loc.city)).filter(Boolean));
if (tenantCities.size === 0) {
OpenToast("Could not determine your tenant's city — showing all zones.", 'warning', 3000);
return [...allLocations, { locationname: 'All', applocationid: 0 }];
}
let matched = allLocations.filter((loc) => tenantCities.has(normCity(loc.locationname)));
if (matched.length === 0) {
matched = allLocations.filter((loc) => {
const hubNorm = normCity(loc.locationname);
return [...tenantCities].some((cityNorm) => hubNorm.includes(cityNorm) || cityNorm.includes(hubNorm));
});
}
if (matched.length === 0) {
OpenToast("No zones matched your tenant's city — showing all zones.", 'warning', 3000);
return [...allLocations, { locationname: 'All', applocationid: 0 }];
}
return matched;
} catch (err) {
OpenToast(err.message, 'error', 2000);
return [{ locationname: 'All', applocationid: 0 }];
@@ -175,10 +235,18 @@ export const fetchPaymentType = async () => [];
export const fetchRidersList = async () => {
try {
const milers = await getMilers();
return (milers || []).map((val) => ({
...val,
label: `${val.displayname || val.authname || ''} | ${val.contactno || ''}`
}));
return (milers || []).map((val) => {
const name = val.displayname || val.authname || '';
return {
...val,
// Only append " | phone" when a phone actually exists — an
// unconditional template literal left a dangling " | " on every
// rider missing contactno, rendering literally in every dropdown
// that falls back to this default label (e.g. OrdersPreview.js,
// Preview.js's Change Rider dialog).
label: val.contactno ? `${name} | ${val.contactno}` : name
};
});
} catch (err) {
// Was `throw`ing after already toasting here — deliveries.js also wires
// its own onError toast on this same query, so a real failure showed
@@ -201,7 +269,16 @@ export const createOptimisationDeliveries = async (deliveryData) => {
// ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== //
export const reconcileSteps = async ({ riders }) => {
logger.debug(`reconcileSteps: posting ${riders?.length ?? 0} rider(s)`, riders);
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/reconcile-steps`, { riders });
// Diagnostic: this is an external, unverified solver contract (see this
// area's CLAUDE.md) — Preview.js's reconcileMutation only clears the
// dirty-rider set (which is what re-enables Assign Orders) when
// response.data.riders is an array. If the solver's real response shape
// is different (wrapped in an envelope, a different key name, etc.),
// that check silently fails every time and Assign Orders can never
// re-enable — logging the exact raw shape here settles it either way.
logger.debug('reconcileSteps: raw response.data', response.data);
return response.data;
};
@@ -232,6 +309,49 @@ export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
// (that one creates a brand-new booking from scratch; wrong here, since
// every order already exists as a Doormile booking pulled from
// GET /admin/bookings).
// Shared by finalCreatedeliveries (here) and Preview.js's pre-commit
// verification UI, so both use the IDENTICAL matching rule rather than two
// copies that could silently drift apart. userid/milerprofileid matching is
// tried first (the solver's rider pool is fed full miler objects for
// Auto/multi-trip mode — see Preview.js's handleCreateDelivery -> `riders:
// autoRiders`, raw GET /admin/milers data carrying both fields per rider)
// but confirmed live that for Bike hypertuning mode NEITHER matches: that
// solver never receives a rider pool at all and assigns from its own
// internal roster, seeded against jupiter rider ids when the integration
// was first built — disconnected from Doormile's id space entirely (see
// jupiter2doormile.md comparison). Falls back to matching the rider's NAME
// (also echoed by the solver, see flattenRiders' rider_name) against each
// miler's displayname/authname — the only other correlatable field.
const normMilerName = (s) => String(s || '').trim().toLowerCase();
export const buildMilerLookup = (milers) => {
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
const byProfileId = new Map((milers || []).map((m) => [String(m.milerprofileid), m]));
const byName = new Map();
(milers || []).forEach((m) => {
[m.displayname, m.authname].forEach((n) => {
const key = normMilerName(n);
if (key && !byName.has(key)) byName.set(key, m);
});
});
return { byUserId, byProfileId, byName };
};
export const resolveMilerForOrder = (order, lookup) => {
const riderUserId = order.rider_id ?? order.userid;
const riderName = order.rider_name ?? order.rider;
const matchedVia = lookup.byUserId.has(String(riderUserId))
? 'userid'
: lookup.byProfileId.has(String(riderUserId))
? 'milerprofileid'
: lookup.byName.has(normMilerName(riderName))
? 'name'
: null;
const rider =
lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName));
return rider?.milerprofileid ? { rider, matchedVia } : null;
};
export const finalCreatedeliveries = async (deliveryData) => {
const deliveries = deliveryData.deliveries || [];
logger.debug(`finalCreatedeliveries: ${deliveries.length} order(s) to assign`);
@@ -253,19 +373,6 @@ export const finalCreatedeliveries = async (deliveryData) => {
});
});
// assign-miler needs a milerprofileid. userid/milerprofileid matching was
// tried first (the solver's rider pool is fed full miler objects — see
// Preview.js's handleCreateDelivery -> `riders: autoRiders`, raw
// GET /admin/milers data carrying both fields per rider) but confirmed
// live that NEITHER matches: the solver's rider_id/userid (e.g. "883")
// isn't in GET /admin/milers under either field. This matches
// Dispatch.js's own Analysis-panel comment — "the workolik solver doesn't
// have our auth/users table" — so its numeric rider ids are its own
// internal numbering, disconnected from Doormile's id space entirely.
// Falls back to matching the rider's NAME (also echoed by the solver,
// see flattenRiders' rider_name) against each miler's displayname/
// authname — the only other correlatable field. Logs the full roster
// alongside so a further mismatch is fully diagnosable from one run.
let milers = [];
try {
milers = (await getMilers()) || [];
@@ -276,47 +383,62 @@ export const finalCreatedeliveries = async (deliveryData) => {
`finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`,
milers.map((m) => ({ userid: m.userid, milerprofileid: m.milerprofileid, name: m.displayname || m.authname }))
);
const milerByUserId = new Map(milers.map((m) => [String(m.userid), m]));
const milerByProfileId = new Map(milers.map((m) => [String(m.milerprofileid), m]));
const normName = (s) => String(s || '').trim().toLowerCase();
const milerByName = new Map();
milers.forEach((m) => {
[m.displayname, m.authname].forEach((n) => {
const key = normName(n);
if (key && !milerByName.has(key)) milerByName.set(key, m);
});
});
const lookup = buildMilerLookup(milers);
// Booking id resolution: confirmed live that guessing a single field name
// (bookingid first, on the assumption the solver passes unknown fields
// through untouched) sends assign-miler a wrong id — a small, sequential-
// looking number that 404s. The AI solver is a separate, unverified
// service (see this area's CLAUDE.md); there's no reliable way to know
// which candidate field it actually preserves. Instead of guessing,
// fetch the tenant's real current booking list once and VALIDATE each
// candidate against it, using whichever one actually matches a real
// booking — this is correct regardless of which field the solver happens
// to preserve, and regardless of which one changes in a future solver
// update.
let realBookingIds = new Set();
try {
const realBookings = (await getBookings(1, 1000)) || [];
realBookingIds = new Set(realBookings.map((b) => String(b.bookingid)));
logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`);
} catch (err) {
logger.error('finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message);
}
const results = await Promise.allSettled(
deliveries.map(async (d) => {
// Booking id: the solver is a separate, unverified service (see this
// area's CLAUDE.md) — falls back through every plausible field name
// an order might carry it under, `bookingid` first since that's what
// orders.js actually sends in and most solvers pass unknown fields
// through untouched.
const bookingId = d.bookingid ?? d.orderheaderid ?? d.deliveryid ?? d.orderid;
const candidates = [d.bookingid, d.orderheaderid, d.deliveryid, d.orderid];
const bookingId = candidates.find((c) => c != null && realBookingIds.has(String(c)));
const riderUserId = d.rider_id ?? d.userid;
const riderName = d.rider_name ?? d.rider;
const matchedVia = milerByUserId.has(String(riderUserId))
? 'userid'
: milerByProfileId.has(String(riderUserId))
? 'milerprofileid'
: milerByName.has(normName(riderName))
? 'name'
: null;
const rider =
milerByUserId.get(String(riderUserId)) ?? milerByProfileId.get(String(riderUserId)) ?? milerByName.get(normName(riderName));
if (bookingId == null || !rider?.milerprofileid) {
const resolved = resolveMilerForOrder(d, lookup);
if (bookingId == null || !resolved) {
const reason =
bookingId == null
? 'no booking id resolved on the order object'
? `no candidate id matched a real booking (tried bookingid=${d.bookingid}, orderheaderid=${d.orderheaderid}, deliveryid=${d.deliveryid}, orderid=${d.orderid})`
: `no miler found for rider id ${riderUserId} / name "${riderName}" (checked userid, milerprofileid, name)`;
logger.error(`finalCreatedeliveries: skipping order — ${reason}`);
throw new Error(`order ${d.orderid ?? bookingId ?? '?'}: ${reason}`);
}
const { rider, matchedVia } = resolved;
logger.debug(`finalCreatedeliveries: order booking ${bookingId} -> rider ${riderUserId} ("${riderName}") matched via ${matchedVia}`);
try {
return await assignMilerToBooking(bookingId, { milerid: rider.milerprofileid });
// doormile-flow.md (confirmed current, authoritative): assign-miler's
// body key is `mileruserid`, and it's the miler's userid — a
// DIFFERENT identity space than milerprofileid, which is what admin
// miler endpoints (notify, block, etc.) key on instead. Sending
// { milerid: rider.milerprofileid } was wrong on both the key name
// and the value's identity space — the actual root cause of the
// persistent 404s on this call.
await assignMilerToBooking(bookingId, { mileruserid: rider.userid });
// Return the REAL resolved milerprofileid, not the solver's own
// rider_id/userid — the caller (Preview.js) needs this for
// notifyRider, which takes a milerprofileid specifically. Before
// this, Preview.js notified using the raw solver id directly, which
// is neither a real userid nor a milerprofileid (see the matching
// comment above) — so rider push notifications were going out with
// a bogus id and very likely silently failing server-side.
return { milerprofileid: rider.milerprofileid };
} catch (err) {
logger.error(
`finalCreatedeliveries: assign-miler failed for booking ${bookingId}`,
@@ -337,7 +459,12 @@ export const finalCreatedeliveries = async (deliveryData) => {
if (failed.length) {
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
}
return { success: true, assigned: deliveries.length - failed.length, failed: failed.length };
const resolvedMilerProfileIds = [
...new Set(
results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid)
)
];
return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds };
};
// ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== //
// Also part of the optimiser pipeline (routes.workolik.com / routemate.workolik.com) — untouched.
@@ -486,7 +613,16 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
return {
rows,
nextPage: rows.length === Number(rowsPerPage) ? pageParam + 1 : undefined
// Whether to fetch another page must be based on the RAW bookings page
// (bookings.length), not the post-filter `rows.length` — most bookings on
// any given page are still pending, not dispatched, so the filtered count
// almost never equals rowsPerPage. Comparing the filtered count against
// rowsPerPage (the previous logic) made pagination stop after page 1 in
// virtually every real dataset, silently hiding dispatched/assigned
// orders that live beyond the first `rowsPerPage` bookings — e.g. a
// booking just created and assigned wouldn't show on the Deliveries page
// at all once the tenant has more than one page's worth of bookings.
nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};
@@ -541,19 +677,27 @@ export const cancelDeliveryAPI = async (selectedRow, cancelFeed) =>
export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid);
// ==============================|| changeRiderAPI (deliveries) ||============================== //
// POST /admin/bookings/:id/assign-miler has no documented body — `milerid` as
// the JSON key is a guess (unverified, this is a write endpoint we didn't
// test live), but the VALUE must be the miler's milerprofileid regardless of
// key name: selectedRider comes straight from getMilers(), whose own .userid
// field is a different resource (confirmed live via GET /admin/milers/:id).
// doormile-flow.md (confirmed current, authoritative) settles this: the body
// is { "mileruserid": <miler's userid> } — the previous guess here (`milerid`
// key, `milerprofileid` value) was wrong on both counts. Admin miler
// endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the
// one exception that wants userid instead — "different identity spaces on
// adjacent endpoints," per that doc's own wording. selectedRider comes
// straight from getMilers(), which carries both fields on the same object.
export const changeRiderAPI = async (selectedRider, selectedRow) =>
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { milerid: selectedRider.milerprofileid });
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid });
// ==============================|| updateDeliveryAPI (deliveries) ||============================== //
// No amount/notes field exists on PUT /admin/consignments/:id/status — closest
// available write is a status update. Free-text amount/notes edits have no home
// in the new API yet.
export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.deliveryid ?? orderData.consignmentid, orderData);
// Target endpoint is consignment-scoped (/admin/consignments/:id/status), so
// this needs the real consignmentid, not a booking id. deliveryid on a
// deliveries-page row is always b.bookingid (see fetchDeliveries) — always
// truthy, so `deliveryid ?? consignmentid` never actually fell through to
// consignmentid even when it was present, silently calling the endpoint
// with the wrong kind of id on every Update Status submit.
export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.consignmentid ?? orderData.deliveryid, orderData);
// ==============================|| getalltenants (tenants) ||============================== //

View File

@@ -346,6 +346,20 @@ export const assignMilerToBooking = async (id, data) => {
return response.data;
};
// Doormile-native bulk assign: picks real milers via Redis GEO + AI scoring, no
// external solver involved. Commits server-side in this one call — unlike the
// workolik solver flow, there's no separate preview/reconcile/commit step.
// Sequencing (POST /optimization/doormile/sequence) is not deployed yet, so
// riders with more than one stop come back unsequenced (step: 0) — see
// doormile-flow.md's State table.
export const batchAssignBookings = async (bookingIds, maxPerRider = 5) => {
const response = await doormileAxios.post('/hub/bookings/batch-assign', {
bookingids: bookingIds,
max_per_rider: maxPerRider
});
return response.data;
};
export const assignVehicleToBooking = async (id, data) => {
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data);
return response.data;

View File

@@ -85,7 +85,7 @@ const soft = (c) => a(c, '18');
const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55');
const BRAND = '#662582';
const BRAND = '#C01227';
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
const SoftPaper = (props) => (
@@ -199,20 +199,30 @@ const ClientsPricing = () => {
const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]);
const locationMap = useMemo(
() => new Map((locations || []).filter((l) => l.applocationid).map((l) => [l.applocationid, l])),
() => new Map((locations || []).filter((l) => l.applocationid).map((l) => [String(l.applocationid), l])),
[locations]
);
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]);
const tenantName = (row) => row.tenantname || tenantMap.get(row.tenantid)?.tenantname || (row.tenantid ? `Tenant #${row.tenantid}` : '—');
const zoneName = (row) =>
row.applocation || locationMap.get(row.applocationid)?.locationname || (row.applocationid ? `Zone #${row.applocationid}` : '—');
row.applocation ||
locationMap.get(String(row.applocationid))?.locationname ||
(row.applocationid ? `Zone #${row.applocationid}` : '—');
// getallpricing() takes no zone arg (see api.js) — the LocationAutocomplete
// only ever changed the header subtitle text, not which rows were shown.
// Filter client-side instead; pricing rows already carry applocationid
// (used by zoneName above), so this is the same real field, just applied.
const zonePricing = useMemo(() => (appId ? pricing.filter((r) => r.applocationid === appId) : pricing), [pricing, appId]);
// Compared as strings — applocationid on a pricing row (GET /admin/pricing)
// and on a hub/location (GET /admin/hubs, what the picker's appId comes
// from) are two independently-serialized resources with no guarantee they
// agree on number-vs-string; a raw === silently zeroed out every row
// whenever they didn't match, making the table look empty for any zone.
const zonePricing = useMemo(
() => (appId ? pricing.filter((r) => String(r.applocationid) === String(appId)) : pricing),
[pricing, appId]
);
const rows = useMemo(() => {
if (!debouncedSearch) return zonePricing;

View File

@@ -75,13 +75,15 @@ const Createcustomer = () => {
});
// LocationAutocomplete only reports back applocationid/locationname — it
// caches the full hub list (with lat/lng) in localStorage('applocations')
// caches the full hub list (with lat/lng) in localStorage under a
// tenant-scoped key (applocations_<tenantid>, see LocationAutocomplete.js)
// as a side effect, so pull the coordinates for the address-search bias
// from there rather than duplicating the GET /admin/hubs call.
useEffect(() => {
if (!appId) return;
try {
const hubs = JSON.parse(localStorage.getItem('applocations') || '[]');
const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`;
const hubs = JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]');
const hub = hubs.find((h) => h.applocationid === appId);
if (hub?.latitude) {
setAppLocaLat(hub.latitude);

View File

@@ -9,7 +9,6 @@ import {
Button,
Grid,
IconButton,
InputLabel,
Paper,
Stack,
Table,
@@ -24,6 +23,7 @@ import {
useMediaQuery,
useTheme
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import {
MdPeopleAlt,
MdOutlinePeopleAlt,
@@ -43,6 +43,7 @@ import Loader from 'components/Loader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
@@ -54,6 +55,13 @@ import { enqueueSnackbar } from 'notistack';
// all. PATCH /admin/customers/:id is the only mutation the API exposes;
// there is no create/delete here by design ("Tenant-scoped through their
// bookings" — a customer only exists once they've ordered through a tenant).
//
// The edit dialog replicates nearle_console_express's customer edit dialog
// field-for-field (Name, Contact, Address, Location, City, State, Postcode,
// Landmark, Latitude, Longitude) — same layout, same AddressAutocomplete
// wiring — but only `name/phone/email` are ever sent in the PATCH body; the
// address fields have nowhere to persist to on this API (same "collected but
// not persisted" pattern as `pages/nearle/clients/createCustomer.js`).
// ============================================================================
const DT = {
@@ -97,6 +105,9 @@ const Customers = () => {
const [debouncedSearch, setDebouncedSearch] = useState('');
const [editRow, setEditRow] = useState(null);
const [form, setForm] = useState({});
const [addressInput, setAddressInput] = useState('');
const [pickAddress, setPickAddress] = useState({});
const [latLng, setLatLng] = useState({ latitude: '', longitude: '' });
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
@@ -139,6 +150,44 @@ const Customers = () => {
phone: row.phone || '',
email: row.email || ''
});
setAddressInput(row.address || '');
setPickAddress({
doorno: row.doorno || '',
suburb: row.suburb || '',
city: row.city || '',
state: row.state || '',
postcode: row.postcode || '',
landmark: row.landmark || ''
});
setLatLng({ latitude: row.latitude || '', longitude: row.longitude || '' });
};
// Same address_components parsing as createCustomer.js's handlePlaceSelected —
// AddressAutocomplete shapes Nominatim results to look like a Google Places `place`.
const handleAddressPlaceSelected = (place) => {
setLatLng({ latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng() });
const parsed = { suburb: '', city: '', state: '', postcode: '' };
place.address_components.forEach((component) => {
component.types.forEach((type) => {
switch (type) {
case 'sublocality_level_1':
case 'sublocality':
parsed.suburb = component.long_name;
break;
case 'locality':
parsed.city = component.long_name;
break;
case 'administrative_area_level_1':
parsed.state = component.long_name;
break;
case 'postal_code':
parsed.postcode = component.long_name;
break;
}
});
});
setPickAddress((p) => ({ ...p, ...parsed }));
setAddressInput(place.formatted_address);
};
return (
@@ -188,13 +237,21 @@ const Customers = () => {
background: '#fff'
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={1.25}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'stretch', sm: 'center' }}
justifyContent="space-between"
spacing={1.25}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={18} />
</AccentAvatar>
<Stack>
<Typography variant="caption" sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}>
<Typography
variant="caption"
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
>
Directory
</Typography>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
@@ -388,7 +445,14 @@ const Customers = () => {
)}
</Paper>
<Dialog open={!!editRow} onClose={() => setEditRow(null)} maxWidth="xs" fullWidth PaperProps={{ sx: { borderRadius: 3 } }}>
<Dialog
open={!!editRow}
onClose={() => setEditRow(null)}
maxWidth="lg"
fullWidth
fullScreen={isMobile}
PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}
>
<DialogTitle
sx={{
background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)`,
@@ -401,7 +465,9 @@ const Customers = () => {
<FaRegEdit style={{ fontSize: 18 }} />
</Avatar>
<Stack>
<Typography sx={{ fontSize: 11, fontWeight: 700, opacity: 0.85, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1 }}>
<Typography
sx={{ fontSize: 11, fontWeight: 700, opacity: 0.85, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1 }}
>
Customer
</Typography>
<Typography sx={{ fontWeight: 800, fontSize: '1.1rem', lineHeight: 1.2, mt: 0.25 }}>
@@ -411,16 +477,22 @@ const Customers = () => {
</Stack>
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}>
<Stack spacing={1}>
<InputLabel>Name</InputLabel>
<TextField fullWidth value={form.name || ''} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Stack>
<Stack spacing={1}>
<InputLabel>Phone Number</InputLabel>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Customer Name</Typography>
<TextField
variant="outlined"
fullWidth
value={form.name || ''}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Contact Number</Typography>
<Stack direction="row" spacing={1}>
<TextField value="+91" disabled sx={{ width: 64, cursor: 'not-allowed' }} />
<TextField variant="outlined" value="+91" disabled sx={{ width: 64, cursor: 'not-allowed' }} />
<TextField
variant="outlined"
fullWidth
value={form.phone || ''}
inputProps={{ maxLength: 10, inputMode: 'numeric', pattern: '[0-9]*' }}
@@ -428,18 +500,107 @@ const Customers = () => {
InputProps={{ startAdornment: <MdOutlinePhoneAndroid size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/>
</Stack>
</Stack>
<Stack spacing={1}>
<InputLabel>Email</InputLabel>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Email</Typography>
<TextField
variant="outlined"
fullWidth
type="email"
value={form.email || ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
InputProps={{ startAdornment: <MdMail size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" sx={{ color: DT.textMuted, fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved
on submit.
</Typography>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Address</Typography>
<AddressAutocomplete
id="edit-customer-address"
fullWidth
value={addressInput}
onChange={(text) => setAddressInput(text)}
onPlaceSelected={handleAddressPlaceSelected}
TextFieldProps={{
variant: 'outlined',
InputProps: {
endAdornment: (
<IconButton
onClick={() => {
setAddressInput('');
setPickAddress((p) => ({ ...p, suburb: '', city: '', state: '', postcode: '' }));
setLatLng({ latitude: '', longitude: '' });
}}
size="small"
>
<CloseIcon fontSize="small" />
</IconButton>
)
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Location</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.suburb || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, suburb: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>City</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.city || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, city: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>State</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.state || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, state: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Postcode</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.postcode || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, postcode: e.target.value }))}
/>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Landmark</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.landmark || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, landmark: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6} sx={{ cursor: 'not-allowed' }}>
<Typography sx={{ mb: 1 }}>Latitude</Typography>
<TextField variant="outlined" fullWidth disabled value={latLng.latitude || ''} sx={{ cursor: 'not-allowed' }} />
</Grid>
<Grid item xs={12} sm={6} sx={{ cursor: 'not-allowed' }}>
<Typography sx={{ mb: 1 }}>Longitude</Typography>
<TextField variant="outlined" fullWidth disabled value={latLng.longitude || ''} sx={{ cursor: 'not-allowed' }} />
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setEditRow(null)} sx={{ color: DT.textSecondary }}>

View File

@@ -5,7 +5,9 @@ import { useState, useEffect, Fragment, useRef, useMemo } from 'react';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import { notifyMiler } from 'pages/api/doormileApi';
import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi';
import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { useTheme } from '@mui/material/styles';
import {
MdOutlineDateRange,
@@ -213,7 +215,12 @@ const getRowBatchId = (row) => {
// so a row that lands in Evening Batch on the dispatch page must also land
// in Evening Batch here — otherwise an operator running in IST/PST/etc.
// sees different totals on the two pages for the same underlying rows.
const d = dayjs(t);
// parseDoormileTimestamp (not bare dayjs()) strips a false trailing Z some
// Doormile timestamps carry (see utils/doormileTimestamp.js) — without
// this, a booking assigned "just now" could get shifted +5:30 (IST) past
// every batch window into a gap, silently vanishing from the table even
// though it was correctly assigned server-side.
const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null;
const h = d.hour() + d.minute() / 60;
for (const b of BATCH_OPTIONS) {
@@ -280,6 +287,58 @@ const Deliveries = () => {
const [deliveryamount, setDeliveryamount] = useState();
const [notes, setNotes] = useState('');
const [currentorder, setCurrentorder] = useState({});
// Real Actual KMs for the Update Status dialog, computed from real GPS
// ping history instead of leaving the field permanently blank (there is
// no backend-stored actual-distance field — see fetchDeliveries in
// api.js). Same sort -> Kalman-smooth -> polyline-length pipeline
// Dispatch.js's Compare feature already uses to show a real "actual km"
// figure, just reused here (see dispatchShared.js). Keyed off
// consignmentid, NOT deliveryid/bookingid — GET /admin/consignments/:id/logs
// is consignment-scoped (the same id-class mix-up already fixed in
// updateDeliveryAPI this session).
const { data: computedActualKm } = useQuery({
queryKey: ['deliveryActualKm', currentorder?.consignmentid],
queryFn: async () => {
let rows = [];
try {
rows = (await getConsignmentLogs(currentorder.consignmentid)) || [];
} catch {
return null;
}
const sorted = rows
.map((r) => {
const ts = r?.logdate ? dayjs(r.logdate) : null;
return {
lat: parseFloat(r?.latitude ?? r?.lat),
lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon),
logdate: r?.logdate,
_ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER
};
})
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.sort((a, b) => a._ts - b._ts);
// Fewer than 2 usable pings (order not yet delivered, or no telemetry
// recorded) — a real, expected case, not an error. Leave the field
// to its existing blank/manual-entry behavior.
if (sorted.length < 2) return null;
const smoothed = kalmanSmoothGps(sorted);
const km = polylineLengthKm(smoothed.map((p) => [p.lat, p.lng]));
return km > 0 ? km : null;
},
enabled: dialogopen && !!currentorder?.consignmentid,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false
});
// Only pre-fill while the field is still empty — never overwrite an
// operator's own edit (or an earlier pre-fill) if this query happens to
// re-run while the dialog is already open.
useEffect(() => {
if (computedActualKm != null && !cumulativekms) {
setCumulativeKms(Number(computedActualKm.toFixed(2)));
}
}, [computedActualKm]);
const [deliverylat, setDeliverylat] = useState('');
const [deliverylong, setDeliverylong] = useState('');
const [currentStatus, setCurrentStatus] = useState('pending');
@@ -311,7 +370,6 @@ const Deliveries = () => {
// batch matching the current UTC hour (the operator is most likely curious
// about "now"); never `null` since there's no longer an "All" option.
const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId);
const roleid = localStorage.getItem('roleid');
useEffect(() => {
setTenantid(0);
@@ -534,7 +592,21 @@ const Deliveries = () => {
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
});
const countSourceRows = useMemo(() => (countSourceData?.pages || []).flatMap((p) => p.rows || []), [countSourceData]);
// Newest activity first — an order just created and assigned should be the
// very first row an operator sees, not buried wherever GET /admin/bookings
// happens to return it (unconfirmed/unspecified server-side order). Every
// row here has been dispatched at least once (fetchDeliveries filters to
// that), so assigntime (booking.updatedat) is the more recent of the two
// timestamps on a fresh assignment; orderdate (createdat) covers the rare
// row missing it. parseDoormileTimestamp (not bare dayjs()) avoids the
// false-Z parsing bug documented in utils/doormileTimestamp.js.
const countSourceRows = useMemo(() => {
const rows = (countSourceData?.pages || []).flatMap((p) => p.rows || []);
return [...rows].sort(
(a, b) =>
parseDoormileTimestamp(b.assigntime || b.orderdate).valueOf() - parseDoormileTimestamp(a.assigntime || a.orderdate).valueOf()
);
}, [countSourceData]);
useEffect(() => {
if (countHasNext && !countIsFetchingNext) countFetchNext();
@@ -1097,7 +1169,7 @@ const Deliveries = () => {
<Autocomplete
disablePortal
options={locationlist || []}
getOptionLabel={(option) => `${option.locationname} (${option.suburb})` || ''}
getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')}
value={locationValue}
PaperComponent={SoftPaper}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
@@ -1144,7 +1216,10 @@ const Deliveries = () => {
disabled={riderListIsLoading}
options={ridersList}
PaperComponent={SoftPaper}
getOptionLabel={(option) => `${option.displayname || option.authname || ''} (${option.contactno || ''})`}
getOptionLabel={(option) => {
const name = option?.displayname || option?.authname || '';
return option?.contactno ? `${name} (${option.contactno})` : name;
}}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
onChange={(e, value, reason) => {
if (reason === 'clear') setRiderid(0);
@@ -1494,7 +1569,7 @@ const Deliveries = () => {
<MobileFieldGrid>
<MobileField label="Order / Location" full>
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
{`${row.locationname}-(${row.locationsuburb})`}
{row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.orderid} · {row.deliveryid}
@@ -1546,8 +1621,8 @@ const Deliveries = () => {
</MobileField>
<MobileField label="Kms · plan / act">
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
<Box sx={chipSx('#ef4444')}>{row.kms || 0} km</Box>
<Box sx={chipSx('#10b981')}>{row.cumulativekms || 0} km</Box>
<Box sx={chipSx('#ef4444')}>{Number(row.kms || 0).toFixed(2)} km</Box>
<Box sx={chipSx('#10b981')}>{Number(row.cumulativekms || 0).toFixed(2)} km</Box>
</Stack>
</MobileField>
<MobileField label="Amount · chg / amt">
@@ -1833,7 +1908,7 @@ const Deliveries = () => {
<TableCell align="left">
<Tooltip title="Location Name-Suburb" placement="top">
<Typography variant="subtitle1" noWrap>
{`${row.locationname}-(${row.locationsuburb})`}
{row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
</Typography>
</Tooltip>
@@ -1844,12 +1919,18 @@ const Deliveries = () => {
{row.orderid}
</Typography>
</Tooltip>
{/* express-console-api.md: Doormile timestamps are IST
wall-clock with no timezone marker — .utc() was
reinterpreting that naive string as local time and
shifting it back 5:30, showing the wrong time (and
sometimes the wrong day). Parse bare, matching the
already-correct convention in getRowBatchId above. */}
<Tooltip title="Ordered date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.orderdate).utc().format('DD/MM/YYYY')}
{dayjs(row.orderdate).format('DD/MM/YYYY')}
</Typography>
<Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.orderdate).utc().format('hh:mm A')}
{dayjs(row.orderdate).format('hh:mm A')}
</Typography>
</Tooltip>
</Stack>
@@ -1862,44 +1943,62 @@ const Deliveries = () => {
</Tooltip>
<Tooltip title="Delivery date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')}
{dayjs(row.deliverydate).format('DD/MM/YYYY')}
</Typography>
<Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.deliverydate).utc().format('hh:mm A')}
{dayjs(row.deliverydate).format('hh:mm A')}
</Typography>
</Tooltip>
</Stack>
</Stack>
</TableCell>
{/* pickup */}
<TableCell align="left">
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{/* pickup — capped to the same maxWidth as drop below, with CSS
ellipsis truncation, so one long address can't stretch this
column wider than its neighbour. row.pickuplocation is just
the full raw address (see fetchDeliveries in api.js), not a
pre-shortened name, so the old `.slice(0, 14)` fallback never
actually ran — it was always overridden by the untruncated
value on its left. Full text still available via the tooltip. */}
<TableCell align="left" sx={{ maxWidth: 190 }}>
<Stack sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{row.pickupcustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.pickupcontactno}
</Typography>
<Tooltip title={row.Pickupaddress} sx={{ whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
{row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : '—')}
<Tooltip title={row.Pickupaddress || ''}>
<Typography
variant="caption"
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
>
{row.pickuplocation || row.Pickupaddress || '—'}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* drop */}
<TableCell align="left">
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{/* drop — same maxWidth/truncation treatment as pickup above. */}
<TableCell align="left" sx={{ maxWidth: 190 }}>
<Stack sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{row.deliverycustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.deliverycontactno}
</Typography>
<Tooltip title={row.deliveryaddress}>
<Typography variant="caption" sx={{ color: DT.textMuted, whiteSpace: 'nowrap' }}>
{row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : '—')}
<Tooltip title={row.deliveryaddress || ''}>
<Typography
variant="caption"
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
>
{row.deliverylocation || row.deliveryaddress || '—'}
</Typography>
</Tooltip>
</Stack>
@@ -1973,7 +2072,7 @@ const Deliveries = () => {
minWidth: 75
}}
>
{row.kms || 0} km
{Number(row.kms || 0).toFixed(2)} km
</Box>
</Tooltip>
<Tooltip title="Actual KMS" placement="top">
@@ -1994,7 +2093,7 @@ const Deliveries = () => {
minWidth: 75
}}
>
{row.cumulativekms || 0} km
{Number(row.cumulativekms || 0).toFixed(2)} km
</Box>
</Tooltip>
</Stack>
@@ -2371,27 +2470,29 @@ const Deliveries = () => {
Change Rider
</MenuItem>
)}
{(roleid == 1 || roleid == 2) && (
<MenuItem
onClick={() => {
setKms(selectedRow.kms);
setCumulativeKms(selectedRow.cumulativekms);
setDeliverylat(selectedRow.droplat);
setDeliverylong(selectedRow.droplon);
setNotes(selectedRow.notes);
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
setUpdateStatus(selectedRow.orderstatus || 'delivered');
setCurrentorder(selectedRow);
setDialogopen(true);
handleMenuClose();
}}
>
<AccentAvatar color="#10b981" size={22}>
<MdCheckCircle size={13} />
</AccentAvatar>
Update Status
</MenuItem>
)}
{/* Was gated to roleid 1/2 (staff-only), matching the reference app's
identical gate — opened to every login per explicit decision, so
tenant operators can reach the same Update Status fields staff
already could. */}
<MenuItem
onClick={() => {
setKms(selectedRow.kms);
setCumulativeKms(selectedRow.cumulativekms);
setDeliverylat(selectedRow.droplat);
setDeliverylong(selectedRow.droplon);
setNotes(selectedRow.notes);
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
setUpdateStatus(selectedRow.orderstatus || 'delivered');
setCurrentorder(selectedRow);
setDialogopen(true);
handleMenuClose();
}}
>
<AccentAvatar color="#10b981" size={22}>
<MdCheckCircle size={13} />
</AccentAvatar>
Update Status
</MenuItem>
{selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && (
<MenuItem
sx={{ color: '#ef4444 !important' }}
@@ -2539,7 +2640,10 @@ const Deliveries = () => {
fullWidth
options={ridersList}
PaperComponent={SoftPaper}
getOptionLabel={(option) => `${option.displayname || option.authname || ''} (${option.contactno || ''})`}
getOptionLabel={(option) => {
const name = option?.displayname || option?.authname || '';
return option?.contactno ? `${name} (${option.contactno})` : name;
}}
onChange={(e, value) => {
setSelectedRider(value);
logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None');
@@ -2922,6 +3026,7 @@ const Deliveries = () => {
} else {
updateDeliveryMutation.mutate({
deliveryid: currentorder.deliveryid,
consignmentid: currentorder.consignmentid,
orderheaderid: currentorder.orderheaderid,
orderstatus: updateStatus,
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),

View File

@@ -58,6 +58,7 @@ import ProfitabilitySection from './ProfitabilitySection';
import ActiveSection from './ActiveSection';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
STATUS_STYLES,
getStatusStyle,
@@ -66,7 +67,10 @@ import {
STEP_PALETTE,
stepColor,
isActiveDelivery,
getActiveOrder
getActiveOrder,
haversineKm,
polylineLengthKm,
kalmanSmoothGps
} from './dispatchShared';
import CompareDataPanel from './CompareDataPanel';
import './Dispatch.css';
@@ -224,7 +228,12 @@ const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
const str = String(t).trim();
// Skip bare date strings — no time component, would always parse to midnight.
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
const d = dayjs(t);
// parseDoormileTimestamp strips a false trailing Z some Doormile timestamps
// carry (see utils/doormileTimestamp.js) — bare dayjs(t) would treat that as
// a real UTC instant and shift it +5:30 (IST), potentially bucketing a
// just-assigned row into the wrong slot or out of every slot entirely.
// deliveries.js's getRowBatchId uses the same parse so both pages agree.
const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null;
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which
// starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
@@ -273,274 +282,9 @@ function MapAutoResize({ trigger }) {
return null;
}
// Haversine distance between two [lat, lng] points in kilometers. Good to
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
// polyline so the Compare delta panel can show "actual km" without depending
// on the backend's actualkms field (which can be stale or missing).
function haversineKm(a, b) {
const R = 6371; // km
const toRad = (d) => (d * Math.PI) / 180;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
function polylineLengthKm(points) {
if (!Array.isArray(points) || points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
total += haversineKm(points[i - 1], points[i]);
}
return total;
}
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
//
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
// velocity dynamics with random acceleration as process noise; measurement
// model H = [1, 0] (we measure position only).
//
// Pipeline:
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
// forward filter even with the in-loop Mahalanobis gate enabled.
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
// innovation exceeds the gate are not used to update; the prediction
// is kept as the posterior. Stores prior + posterior moments at each
// step so the backward pass can run.
// 3. Backward RTS smoother — refines every step using ALL future
// observations. Logs are fetched in one shot (not streamed) so we
// can afford the second pass; the accuracy lift is biggest near the
// start of the trail and through turns the forward pass under-corrects.
//
// Tuning (all in degrees² since pings are in lat/lng):
// processNoise (q) — random-acceleration variance (deg²/s²). Default
// tuned for urban two-wheelers (~1 m/s² accel).
// Lower = smoother but slower to follow sharp turns.
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
// which matches consumer GPS in open urban areas.
// Bump for dense canyons.
// outlierGate — Mahalanobis² threshold for in-loop rejection.
// 9.0 = 3σ (≈ 99.7% of inliers pass).
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
// 120 km/h covers any legal two-wheeler movement
// plus margin; anything above is GPS error.
function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
);
if (cleanedPings.length === 0) return [];
if (cleanedPings.length === 1) {
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
}
const processNoise =
options.processNoise != null ? options.processNoise : 1e-10;
const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 2e-9;
const outlierGate =
options.outlierGate != null ? options.outlierGate : 9.0;
const maxSpeedKmh =
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// 2. Scan forward to find the first valid starting anchor
let startIdx = 0;
while (startIdx < cleanedPings.length - 1) {
const p0 = cleanedPings[startIdx];
const p1 = cleanedPings[startIdx + 1];
const ts0 = tsOf(p0);
const ts1 = tsOf(p1) || ts0 + 1000;
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh <= maxSpeedKmh) {
break;
} else {
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
if (startIdx + 2 < cleanedPings.length) {
const p2 = cleanedPings[startIdx + 2];
const ts2 = tsOf(p2) || ts1 + 1000;
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
const speedKmh12 = (km12 / dtSec12) * 3600;
if (speedKmh12 <= maxSpeedKmh) {
startIdx = startIdx + 1;
continue;
}
}
startIdx++;
}
}
// 3. Teleport filter starting from the valid anchor
const accepted = [cleanedPings[startIdx]];
let lastTs = tsOf(cleanedPings[startIdx]);
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
const p = cleanedPings[i];
const ts = tsOf(p) || lastTs + 1000;
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
const prev = accepted[accepted.length - 1];
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh > maxSpeedKmh) continue;
accepted.push(p);
lastTs = ts;
}
if (accepted.length < 2) {
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
}
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
// positions parallel to `accepted`.
const smoothAxis = (axisKey) => {
const N = accepted.length;
// Per-step storage for the backward RTS pass.
const xPost = new Array(N); // [pos, vel] posterior after update
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
const xPrior = new Array(N); // predicted mean before update
const pPrior = new Array(N); // predicted cov before update
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
// Initial state: position = first measurement, velocity from the first
// two pings (better than 0 — keeps the start of the trail from lagging
// behind the rider's actual motion). Initial position covariance = r
// (we just measured it); initial velocity covariance is loose so it
// can be refined quickly.
const ts0 = tsOf(accepted[0]);
const ts1 = tsOf(accepted[1]);
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
xPost[0] = [accepted[0][axisKey], v0];
pPost[0] = [measurementNoise, 0, 0, 1];
xPrior[0] = xPost[0].slice();
pPrior[0] = pPost[0].slice();
dtArr[0] = 0;
let prevTs = ts0;
for (let i = 1; i < N; i++) {
const ts = tsOf(accepted[i]) || prevTs + 1000;
const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts;
dtArr[i] = dt;
// ─── Predict ───
// x' = F x where F = [[1, dt], [0, 1]]
const [xPrev, vPrev] = xPost[i - 1];
const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
const dt2 = dt * dt;
const dt3 = dt2 * dt;
const dt4 = dt3 * dt;
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
const np11 = pp11 + dt2 * processNoise;
xPrior[i] = [xPredPos, xPredVel];
pPrior[i] = [np00, np01, np10, np11];
// ─── Update (with Mahalanobis gating) ───
// y = z − Hx' (innovation)
// S = H P' H^T + R (innovation covariance)
// Reject the measurement if mahal² = y²/S exceeds the gate. The
// prediction then carries forward as the posterior — the trail stays
// continuous instead of being yanked toward a bad fix.
const z = accepted[i][axisKey];
const y = z - xPredPos;
const S = np00 + measurementNoise;
const mahal2 = (y * y) / S;
if (mahal2 > outlierGate) {
xPost[i] = [xPredPos, xPredVel];
pPost[i] = [np00, np01, np10, np11];
continue;
}
// K = P' H^T / S
const K0 = np00 / S;
const K1 = np10 / S;
// x = x' + K y
const newPos = xPredPos + K0 * y;
const newVel = xPredVel + K1 * y;
// P = (I − K H) P'
xPost[i] = [newPos, newVel];
pPost[i] = [
(1 - K0) * np00,
(1 - K0) * np01,
np10 - K1 * np00,
np11 - K1 * np01
];
}
// ─── Backward RTS smoother ─────────────────────────────────────────
// x_smooth[N-1] = x_post[N-1]
// For i = N-2 … 0:
// C = P_post[i] · F^T · inv(P_prior[i+1])
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
// P_post · F^T = [[p00 + dt·p01, p01],
// [p10 + dt·p11, p11]]
const xSmooth = new Array(N);
xSmooth[N - 1] = xPost[N - 1].slice();
for (let i = N - 2; i >= 0; i--) {
const dt = dtArr[i + 1];
const [pp00, pp01, pp10, pp11] = pPost[i];
const a = pp00 + dt * pp01;
const b = pp01;
const c = pp10 + dt * pp11;
const d = pp11;
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
const [q00, q01, q10, q11] = pPrior[i + 1];
const det = q00 * q11 - q01 * q10;
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
xSmooth[i] = xPost[i].slice();
continue;
}
const inv00 = q11 / det;
const inv01 = -q01 / det;
const inv10 = -q10 / det;
const inv11 = q00 / det;
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
const c00 = a * inv00 + b * inv10;
const c01 = a * inv01 + b * inv11;
const c10 = c * inv00 + d * inv10;
const c11 = c * inv01 + d * inv11;
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
xSmooth[i] = [
xPost[i][0] + c00 * dxPos + c01 * dxVel,
xPost[i][1] + c10 * dxPos + c11 * dxVel
];
}
return xSmooth.map((s) => s[0]);
};
const lats = smoothAxis('lat');
const lngs = smoothAxis('lng');
return accepted.map((p, i) => ({
lat: lats[i],
lng: lngs[i],
logdate: p.logdate,
_ts: p._ts
}));
}
// haversineKm/polylineLengthKm/kalmanSmoothGps moved to dispatchShared.js so
// deliveries.js's Update Status dialog can compute the same real, GPS-based
// Actual KMs figure instead of leaving that field permanently blank.
// Splits a routed OSRM polyline into per-step segments by finding the
// polyline index closest to each drop waypoint. Returns an array of
@@ -594,7 +338,7 @@ const formatTimeOnly = (t) => {
if (!t) return null;
const d = dayjs(t);
if (!d.isValid()) return String(t);
return d.format('HH:mm:ss');
return d.format('hh:mm A');
};
// Stages the popup walks through, top → bottom, in real-world delivery order.
@@ -981,14 +725,6 @@ const ANALYSIS_BATCH_WINDOWS = [
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }
];
// Tolerant field-name lookup so the Analysis card still renders cleanly even
// if the API response uses slightly different keys than expected.
const analysisPick = (obj, keys) => {
for (const k of keys) {
if (obj && obj[k] != null && obj[k] !== '') return obj[k];
}
return null;
};
const analysisFormatNum = (v) => {
if (v == null) return '—';
if (typeof v === 'number') return v.toLocaleString('en-IN');
@@ -996,14 +732,6 @@ const analysisFormatNum = (v) => {
if (Number.isFinite(n)) return n.toLocaleString('en-IN');
return String(v);
};
const analysisFormatKm = (v) => (v == null ? '—' : `${parseFloat(v).toFixed(1)} km`);
const analysisFormatRupees = (v) => (v == null ? '—' : `₹${parseFloat(v).toFixed(0)}`);
const analysisFormatPct = (v) => {
if (v == null) return '—';
const n = parseFloat(v);
if (!Number.isFinite(n)) return '—';
return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`;
};
// Parse "HH:mm:ss" or "HH:mm" → seconds since midnight. Returns null when the
// string is missing or malformed. Used to compute gantt percentages for the
// rider timelines on the Analysis page — the API ships those fields as bare
@@ -3507,13 +3235,6 @@ const Dispatch = ({
return routes;
};
const toggleRider = (rid) => {
const newActive = new Set(activeRiders);
if (newActive.has(rid)) newActive.delete(rid);
else newActive.add(rid);
setActiveRiders(newActive);
};
return (
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
{!embedded && (
@@ -4617,7 +4338,7 @@ const Dispatch = ({
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -4766,7 +4487,7 @@ const Dispatch = ({
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -4913,7 +4634,7 @@ const Dispatch = ({
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -5120,6 +4841,14 @@ const Dispatch = ({
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" />
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
{compareOpen && (
<CompareMapClickUnpin
onUnpin={() => {
pinnedPopupsRef.current.clear();
setCenterPopupOrder(null);
}}
/>
)}
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
{kitchens

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import logger from '../../../utils/logger';
import {
Autocomplete,
Backdrop,
@@ -26,15 +27,17 @@ import dayjs from 'dayjs';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { HiOutlineArrowLeft } from 'react-icons/hi';
import { IoReload } from 'react-icons/io5';
import { MdTwoWheeler, MdSwapHoriz } from 'react-icons/md';
import { MdTwoWheeler, MdSwapHoriz, MdWarning } from 'react-icons/md';
import {
buildMilerLookup,
createAutomationDeliveries,
createOptimisationDeliveries,
fetchRidersList,
finalCreatedeliveries,
notifyRider,
reconcileSteps
reconcileSteps,
resolveMilerForOrder
} from '../../api/api';
import { OpenToast } from 'components/third-party/OpenToast';
import CSVExport from 'components/third-party/ReactTable';
@@ -152,6 +155,14 @@ const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName })
movedOrder = r.orders[oi];
r.orders.splice(oi, 1);
homeZoneIdx = zi;
// A rider left with zero orders after this move is a ghost entry —
// Dispatch's rider list renders every zone.riders[] entry
// unconditionally, so it would keep showing as a clickable
// 0-trips/0km/₹0 card with nothing inside once its last order is
// reassigned elsewhere. Drop it from the zone entirely.
if (r.orders.length === 0) {
zone.riders.splice(ri, 1);
}
}
}
}
@@ -243,6 +254,15 @@ const applyReconcileResponse = (preview, response) => {
});
}
});
// Same ghost-rider cleanup as moveOrderInPreviewData: if the reconcile
// response came back with an empty orders[] for a rider (every stop it
// had got reassigned elsewhere during reconciliation), don't leave that
// rider sitting in the tree as a 0-trips/0km/₹0 card with nothing inside.
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders = zone.riders.filter((r) => Array.isArray(r.orders) && r.orders.length > 0);
});
} else {
next.zones = [
{
@@ -329,19 +349,17 @@ const Preview = () => {
const autoRiders = stateData.autoRiders || [];
const absentRidersPayload = stateData.absentRidersPayload || [];
const appId = useMemo(() => {
if (stateData.appId) return stateData.appId;
if (typeof window !== 'undefined') {
const v = localStorage.getItem('applocationid');
return v ? Number(v) : 0;
}
return 0;
}, [stateData.appId]);
// fetchRidersList() takes no params — GET /admin/milers is tenant-scoped
// server-side from the auth token, not by appId. Gating this query on
// `!!appId` was wrong: orders.js (the entry point into this page) always
// navigates here with a hardcoded appId of 0 (it has no zone picker at
// all), which made this query permanently disabled unless a stale
// `applocationid` happened to be cached in localStorage from a previous
// Dispatch.js visit — the Change Rider dropdown showed "no riders" for
// anyone who reached this page the normal way.
const { data: ridersList } = useQuery({
queryKey: ['ridersList', appId],
queryKey: ['ridersList'],
queryFn: fetchRidersList,
enabled: !!appId,
staleTime: 5 * 60 * 1000
});
@@ -361,6 +379,24 @@ const Preview = () => {
return [];
}, [reconcileRiders, dispatchPreviewData]);
// Which orders carry a rider id the AI solver assigned that doesn't match
// any real Doormile miler (userid/milerprofileid/name — same rule
// finalCreatedeliveries uses server-side, see api.js). Bike-hypertuning
// mode never sends the solver a rider pool at all, so it assigns from its
// own internal roster — one that predates the Doormile migration and can
// return ids with no live counterpart. Surfacing this here, before
// commit, lets the operator fix it via the existing Change Rider flow
// instead of the batch silently failing (or worse, notifying/assigning
// the wrong person) after Assign is clicked.
const unverifiedOrderIds = useMemo(() => {
const lookup = buildMilerLookup(ridersList || []);
const ids = new Set();
finaldeliveryList.forEach((order) => {
if (!resolveMilerForOrder(order, lookup)) ids.add(String(order.orderid));
});
return ids;
}, [finaldeliveryList, ridersList]);
useEffect(() => {
const filtered = finaldeliveryList.map((item) => ({
zone_name: item.zone_name,
@@ -387,7 +423,17 @@ const Preview = () => {
const notifyRiderMutation = useMutation({
mutationFn: notifyRider,
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
onError: (error) => OpenToast(error.message, 'error', 2000)
onError: (error) => {
// doormileAxios's response interceptor rewrites a failed request's
// rejection to `error.response.data` directly (see utils/doormileAxios.js),
// so `error` here IS the backend's JSON body, not an axios Error — its
// `.message` key only exists if the backend happened to name the field
// that. Logging the raw object is the only reliable way to see what a
// 400 actually complained about (e.g. "no device token", "invalid
// miler") instead of a blank/undefined toast.
logger.error('notifyRiderMutation failed:', error);
OpenToast(error?.message || error?.error || 'Failed to notify rider — see console for details', 'error', 2000);
}
});
const createDeliveryMutation = useMutation({
@@ -415,26 +461,23 @@ const Preview = () => {
// database, which neither the Orders "pending" list nor the Deliveries
// "dispatched" filter ever read (both come from GET /admin/bookings).
mutationFn: finalCreatedeliveries,
onSuccess: () => {
onSuccess: (data) => {
OpenToast('Delivery Created Successfully', 'success', 2000);
setIsLoading(false);
// stateData.rider (a single rider forwarded via navigate() from the
// Orders page) is never actually populated in the real flow — that
// page's navigate() call doesn't include a `rider` key at all — so
// this was a permanent no-op and no rider ever got notified after
// assignment. Notify every rider actually present in the committed
// list instead. notifyRider expects a milerprofileid (not an FCM
// token — the server looks the device up itself); rider_id/userid
// here is the id this page already treats as canonical throughout
// (see flattenRiders/moveOrderInPreviewData above) since it's the
// only rider identifier the solver echoes back — unconfirmed whether
// that's actually a milerprofileid by the time it reaches here.
const notifiedRiderIds = new Set();
finaldeliveryList.forEach((order) => {
const riderId = order.rider_id ?? order.userid;
if (riderId == null || notifiedRiderIds.has(String(riderId))) return;
notifiedRiderIds.add(String(riderId));
notifyRiderMutation.mutate(riderId);
// assignment. Notify every rider finalCreatedeliveries actually
// resolved and assigned (data.resolvedMilerProfileIds — real
// milerprofileids from GET /admin/milers, deduped there). Previously
// this notified using order.rider_id/userid directly, which is the
// solver's own internal rider numbering — confirmed live to NOT be a
// real Doormile userid or milerprofileid (see api.js's
// finalCreatedeliveries) — so every notification went out with a
// bogus id and likely silently failed server-side.
(data?.resolvedMilerProfileIds || []).forEach((milerprofileid) => {
notifyRiderMutation.mutate(milerprofileid);
});
navigate('/doormile/deliveries');
},
@@ -446,9 +489,13 @@ const Preview = () => {
});
const reconcileMutation = useMutation({
mutationFn: reconcileSteps,
mutationFn: (payload) => {
logger.debug('reconcile: sending payload', payload);
return reconcileSteps(payload);
},
onMutate: () => setReconcileLoading(true),
onSuccess: (data) => {
logger.debug('reconcile: response', data);
if (Array.isArray(data?.riders)) {
// Merge: applyReconcileResponse replaces orders for riders present
// in the response and leaves the rest of the cache untouched.
@@ -459,14 +506,17 @@ const Preview = () => {
setDirtyRiderIds((prev) => {
const next = new Set(prev);
data.riders.forEach((r) => next.delete(String(r.rider_id)));
logger.debug('reconcile: dirtyRiderIds after clearing reconciled riders', [...next]);
return next;
});
OpenToast('Steps reconciled — preview updated', 'success', 2000);
} else {
logger.error('reconcile: response had no riders array — dirtyRiderIds NOT cleared, Assign Orders stays disabled', data);
OpenToast('Reconcile returned no rider data', 'warning', 3000);
}
},
onError: (error) => {
logger.error('reconcile: request failed', error?.response?.status, error?.response?.data || error?.message);
OpenToast(error.message || 'Reconcile failed', 'error', 4000);
},
onSettled: () => setReconcileLoading(false)
@@ -513,11 +563,18 @@ const Preview = () => {
OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000);
return;
}
// Same reasoning as the button's disabled state — belt-and-suspenders
// in case this ever fires from somewhere other than that button.
if (unverifiedOrderIds.size > 0) {
OpenToast(`${unverifiedOrderIds.size} order(s) have an unrecognized rider — use Change Rider to fix them first`, 'warning', 4000);
return;
}
setIsLoading(true);
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
};
const handleReconcile = () => {
logger.debug('handleReconcile: dirtyRiderIds', [...dirtyRiderIds], 'reconcileRiders ids', reconcileRiders.map((r) => r.rider_id));
if (!reconcileRiders.length) {
OpenToast('No riders to reconcile', 'warning', 3000);
return;
@@ -529,6 +586,10 @@ const Preview = () => {
dirtyRiderIds.has(String(r.rider_id))
);
if (!dirty.length) {
logger.error(
'handleReconcile: dirtyRiderIds is non-empty but none of them match a rider currently in reconcileRiders — nothing to send, Assign Orders stays disabled',
[...dirtyRiderIds]
);
OpenToast('No edits to reconcile', 'info', 2500);
return;
}
@@ -559,21 +620,42 @@ const Preview = () => {
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
`Rider ${newRiderId}`;
setDispatchPreviewData((prev) =>
moveOrderInPreviewData(prev, {
orderId: selectedOrder.orderid,
oldRiderId: selectedOldRiderId,
newRiderId,
newRiderName
})
);
const moved = moveOrderInPreviewData(dispatchPreviewData, {
orderId: selectedOrder.orderid,
oldRiderId: selectedOldRiderId,
newRiderId,
newRiderName
});
setDispatchPreviewData(moved);
// If that was the old rider's LAST order, moveOrderInPreviewData's own
// ghost-rider cleanup already removed them from the tree entirely (see
// that function). There is nothing left of theirs to reconcile — and
// marking them dirty anyway is a real bug, not just unnecessary: the
// reconcile response can only ever echo back riders that were actually
// sent to it, handleReconcile only sends riders still present in
// reconcileRiders (derived from this same tree), so a rider who no
// longer exists here can NEVER be sent, NEVER come back in the
// response, and therefore NEVER get cleared from dirtyRiderIds —
// permanently stuck at size > 0, permanently disabling Assign Orders.
// Confirmed via logging (reconcile: dirtyRiderIds after clearing
// reconciled riders) that this is exactly what happens.
const oldRiderStillExists = Array.isArray(moved?.zones)
? moved.zones.some((z) => (z.riders || []).some((r) => String(r.rider_id ?? r.userid) === String(selectedOldRiderId)))
: false;
logger.debug('confirmChangeRider: old rider still has orders after move?', oldRiderStillExists, 'oldRiderId', selectedOldRiderId);
// Both riders' step sequences are now potentially stale: the old rider
// lost a stop, the new rider gained one. Mark both as dirty so the next
// Reconcile sends exactly these two.
// Reconcile sends exactly these two — unless the old rider is gone.
setDirtyRiderIds((prev) => {
const next = new Set(prev);
if (selectedOldRiderId != null) next.add(String(selectedOldRiderId));
if (selectedOldRiderId != null) {
if (oldRiderStillExists) next.add(String(selectedOldRiderId));
else next.delete(String(selectedOldRiderId));
}
if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
logger.debug('confirmChangeRider: dirtyRiderIds after change', [...next]);
return next;
});
setHasReconciled(false);
@@ -742,6 +824,7 @@ const Preview = () => {
{r.orders.map((o, idx) => {
const stepNum = o.step ?? idx + 1;
const color = stepColor(Number(stepNum) - 1);
const isUnverified = unverifiedOrderIds.has(String(o.orderid));
return (
<Tooltip
key={`${o.orderid}-${idx}`}
@@ -749,17 +832,20 @@ const Preview = () => {
<Box>
<div>Order #{o.orderid}</div>
<div>{o.deliveryaddress || o.deliverysuburb || ''}</div>
<div style={{ marginTop: 4, opacity: 0.8 }}>Click to change rider</div>
<div style={{ marginTop: 4, opacity: 0.8 }}>
{isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider'}
</div>
</Box>
}
>
<Box
onClick={() => openChangeRider(r, o)}
sx={{
position: 'relative',
width: 36,
height: 36,
borderRadius: '50%',
bgcolor: color,
bgcolor: isUnverified ? '#ef4444' : color,
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
@@ -767,13 +853,27 @@ const Preview = () => {
fontWeight: 800,
fontSize: 14,
cursor: 'pointer',
boxShadow:
'0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
boxShadow: isUnverified
? '0 0 0 2px #fff, 0 0 0 4px #ef4444, 0 1px 3px rgba(15,23,42,0.15)'
: '0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
transition: 'transform 0.15s',
'&:hover': { transform: 'scale(1.08)' }
}}
>
{stepNum}
{isUnverified && (
<MdWarning
size={14}
style={{
position: 'absolute',
top: -5,
right: -5,
color: '#ef4444',
background: '#fff',
borderRadius: '50%'
}}
/>
)}
</Box>
</Tooltip>
);
@@ -823,12 +923,20 @@ const Preview = () => {
>
Back
</Button>
<Tooltip title={dirtyRiderIds.size > 0 ? `Reconcile ${dirtyRiderIds.size} edited rider(s) first` : ''}>
<Tooltip
title={
dirtyRiderIds.size > 0
? `Reconcile ${dirtyRiderIds.size} edited rider(s) first`
: unverifiedOrderIds.size > 0
? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first`
: ''
}
>
<span style={isMobile ? { width: '100%' } : undefined}>
<Button
variant="contained"
fullWidth={isMobile}
disabled={dirtyRiderIds.size > 0}
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery}
>
Assign Orders

View File

@@ -85,3 +85,274 @@ export const getActiveOrder = (orders) => {
});
return sorted.find(isActiveDelivery) || null;
};
// Haversine distance between two [lat, lng] points in kilometers. Good to
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
// polyline so the Compare delta panel can show "actual km" without depending
// on the backend's actualkms field (which can be stale or missing). Also
// reused by deliveries.js's Update Status dialog to compute a real Actual
// KMs figure from GET /admin/consignments/:id/logs — see polylineLengthKm.
export function haversineKm(a, b) {
const R = 6371; // km
const toRad = (d) => (d * Math.PI) / 180;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
export function polylineLengthKm(points) {
if (!Array.isArray(points) || points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
total += haversineKm(points[i - 1], points[i]);
}
return total;
}
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
//
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
// velocity dynamics with random acceleration as process noise; measurement
// model H = [1, 0] (we measure position only).
//
// Pipeline:
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
// forward filter even with the in-loop Mahalanobis gate enabled.
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
// innovation exceeds the gate are not used to update; the prediction
// is kept as the posterior. Stores prior + posterior moments at each
// step so the backward pass can run.
// 3. Backward RTS smoother — refines every step using ALL future
// observations. Logs are fetched in one shot (not streamed) so we
// can afford the second pass; the accuracy lift is biggest near the
// start of the trail and through turns the forward pass under-corrects.
//
// Tuning (all in degrees² since pings are in lat/lng):
// processNoise (q) — random-acceleration variance (deg²/s²). Default
// tuned for urban two-wheelers (~1 m/s² accel).
// Lower = smoother but slower to follow sharp turns.
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
// which matches consumer GPS in open urban areas.
// Bump for dense canyons.
// outlierGate — Mahalanobis² threshold for in-loop rejection.
// 9.0 = 3σ (≈ 99.7% of inliers pass).
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
// 120 km/h covers any legal two-wheeler movement
// plus margin; anything above is GPS error.
export function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
);
if (cleanedPings.length === 0) return [];
if (cleanedPings.length === 1) {
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
}
const processNoise =
options.processNoise != null ? options.processNoise : 1e-10;
const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 2e-9;
const outlierGate =
options.outlierGate != null ? options.outlierGate : 9.0;
const maxSpeedKmh =
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// 2. Scan forward to find the first valid starting anchor
let startIdx = 0;
while (startIdx < cleanedPings.length - 1) {
const p0 = cleanedPings[startIdx];
const p1 = cleanedPings[startIdx + 1];
const ts0 = tsOf(p0);
const ts1 = tsOf(p1) || ts0 + 1000;
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh <= maxSpeedKmh) {
break;
} else {
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
if (startIdx + 2 < cleanedPings.length) {
const p2 = cleanedPings[startIdx + 2];
const ts2 = tsOf(p2) || ts1 + 1000;
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
const speedKmh12 = (km12 / dtSec12) * 3600;
if (speedKmh12 <= maxSpeedKmh) {
startIdx = startIdx + 1;
continue;
}
}
startIdx++;
}
}
// 3. Teleport filter starting from the valid anchor
const accepted = [cleanedPings[startIdx]];
let lastTs = tsOf(cleanedPings[startIdx]);
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
const p = cleanedPings[i];
const ts = tsOf(p) || lastTs + 1000;
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
const prev = accepted[accepted.length - 1];
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh > maxSpeedKmh) continue;
accepted.push(p);
lastTs = ts;
}
if (accepted.length < 2) {
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
}
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
// positions parallel to `accepted`.
const smoothAxis = (axisKey) => {
const N = accepted.length;
// Per-step storage for the backward RTS pass.
const xPost = new Array(N); // [pos, vel] posterior after update
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
const xPrior = new Array(N); // predicted mean before update
const pPrior = new Array(N); // predicted cov before update
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
// Initial state: position = first measurement, velocity from the first
// two pings (better than 0 — keeps the start of the trail from lagging
// behind the rider's actual motion). Initial position covariance = r
// (we just measured it); initial velocity covariance is loose so it
// can be refined quickly.
const ts0 = tsOf(accepted[0]);
const ts1 = tsOf(accepted[1]);
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
xPost[0] = [accepted[0][axisKey], v0];
pPost[0] = [measurementNoise, 0, 0, 1];
xPrior[0] = xPost[0].slice();
pPrior[0] = pPost[0].slice();
dtArr[0] = 0;
let prevTs = ts0;
for (let i = 1; i < N; i++) {
const ts = tsOf(accepted[i]) || prevTs + 1000;
const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts;
dtArr[i] = dt;
// ─── Predict ───
// x' = F x where F = [[1, dt], [0, 1]]
const [xPrev, vPrev] = xPost[i - 1];
const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
const dt2 = dt * dt;
const dt3 = dt2 * dt;
const dt4 = dt3 * dt;
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
const np11 = pp11 + dt2 * processNoise;
xPrior[i] = [xPredPos, xPredVel];
pPrior[i] = [np00, np01, np10, np11];
// ─── Update (with Mahalanobis gating) ───
// y = z − Hx' (innovation)
// S = H P' H^T + R (innovation covariance)
// Reject the measurement if mahal² = y²/S exceeds the gate. The
// prediction then carries forward as the posterior — the trail stays
// continuous instead of being yanked toward a bad fix.
const z = accepted[i][axisKey];
const y = z - xPredPos;
const S = np00 + measurementNoise;
const mahal2 = (y * y) / S;
if (mahal2 > outlierGate) {
xPost[i] = [xPredPos, xPredVel];
pPost[i] = [np00, np01, np10, np11];
continue;
}
// K = P' H^T / S
const K0 = np00 / S;
const K1 = np10 / S;
// x = x' + K y
const newPos = xPredPos + K0 * y;
const newVel = xPredVel + K1 * y;
// P = (I − K H) P'
xPost[i] = [newPos, newVel];
pPost[i] = [
(1 - K0) * np00,
(1 - K0) * np01,
np10 - K1 * np00,
np11 - K1 * np01
];
}
// ─── Backward RTS smoother ─────────────────────────────────────────
// x_smooth[N-1] = x_post[N-1]
// For i = N-2 … 0:
// C = P_post[i] · F^T · inv(P_prior[i+1])
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
// P_post · F^T = [[p00 + dt·p01, p01],
// [p10 + dt·p11, p11]]
const xSmooth = new Array(N);
xSmooth[N - 1] = xPost[N - 1].slice();
for (let i = N - 2; i >= 0; i--) {
const dt = dtArr[i + 1];
const [pp00, pp01, pp10, pp11] = pPost[i];
const a = pp00 + dt * pp01;
const b = pp01;
const c = pp10 + dt * pp11;
const d = pp11;
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
const [q00, q01, q10, q11] = pPrior[i + 1];
const det = q00 * q11 - q01 * q10;
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
xSmooth[i] = xPost[i].slice();
continue;
}
const inv00 = q11 / det;
const inv01 = -q01 / det;
const inv10 = -q10 / det;
const inv11 = q00 / det;
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
const c00 = a * inv00 + b * inv10;
const c01 = a * inv01 + b * inv11;
const c10 = c * inv00 + d * inv10;
const c11 = c * inv01 + d * inv11;
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
xSmooth[i] = [
xPost[i][0] + c00 * dxPos + c01 * dxVel,
xPost[i][1] + c10 * dxPos + c11 * dxVel
];
}
return xSmooth.map((s) => s[0]);
};
const lats = smoothAxis('lat');
const lngs = smoothAxis('lng');
return accepted.map((p, i) => ({
lat: lats[i],
lng: lngs[i],
logdate: p.logdate,
_ts: p._ts
}));
}

View File

@@ -167,7 +167,7 @@ const MobileOrdersList = ({ list, aiMode }) => {
<MobileField label="KMS">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top">
<Chip size="small" label={`${val.kms} km`} color="error" />
<Chip size="small" label={`${Number(val.kms || 0).toFixed(2)} km`} color="error" />
</Tooltip>
<Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
@@ -559,7 +559,7 @@ const OrdersPreview = () => {
<TableCell align="center">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top">
<Chip size="small" label={`${val.kms} km`} color="error" />
<Chip size="small" label={`${Number(val.kms || 0).toFixed(2)} km`} color="error" />
</Tooltip>
<Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
@@ -668,7 +668,7 @@ const OrdersPreview = () => {
<TableCell align="center">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top">
<Chip size="small" label={`${val.kms} km`} color="error" />
<Chip size="small" label={`${Number(val.kms || 0).toFixed(2)} km`} color="error" />
</Tooltip>
<Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import { enqueueSnackbar } from 'notistack';
import { useState, useEffect, useMemo, useCallback, Fragment, useRef } from 'react';
import logger from '../../../utils/logger';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
@@ -53,7 +54,8 @@ import {
MdNotes,
MdMyLocation,
MdOutlineFlag,
MdPerson
MdPerson,
MdFlashOn
} from 'react-icons/md';
import { DeleteOutlined } from '@ant-design/icons';
import Loader from 'components/Loader';
@@ -62,8 +64,9 @@ import CircularLoader from 'components/CircularLoader';
import AiImage from '../../../assets/images/aiImage.png';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate, useLocation } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders } from '../../api/api';
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack } from 'pages/api/doormileApi';
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack, batchAssignBookings, getMilers } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
// ============================================================================
// Design tokens — shared with the rest of the redesigned operator pages.
@@ -97,13 +100,17 @@ const BRAND = '#C01227';
const BRAND_LIGHT = '#D25463';
// Semantic per-row status palette, keyed by GET /admin/bookings' real `status`
// enum (lowercased — StatusBadge lowercases before lookup). Only
// pending_pickup and converted_to_consignment have been observed against the
// live API so far; the rest are reasonable guesses at likely sibling states
// with a safe unknown-status fallback in StatusBadge below.
// enum (lowercased — StatusBadge lowercases before lookup). pending_pickup,
// miler_assigned, pickup_scheduled, converted_to_consignment and cancelled are
// now confirmed live (see the ORDERS_STATUS_TABS comment above — a real
// booking status breakdown logged from an actual assign-miler call); the rest
// remain reasonable guesses at likely sibling states with a safe
// unknown-status fallback in StatusBadge below.
const ROW_STATUS_META = {
pending_pickup: { label: 'Pending Pickup', color: '#f59e0b', icon: MdHourglassEmpty },
pending_assignment: { label: 'Pending Assignment', color: '#f59e0b', icon: MdHourglassEmpty },
miler_assigned: { label: 'Rider Assigned', color: '#6366f1', icon: MdCheckCircle },
pickup_scheduled: { label: 'Pickup Scheduled', color: '#6366f1', icon: MdCheckCircle },
converted_to_consignment: { label: 'Converted', color: '#6366f1', icon: MdCheckCircle },
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
@@ -115,11 +122,42 @@ const ROW_STATUS_META = {
// below. `status` values here are the raw, lowercased GET /admin/bookings
// enum (matches ROW_STATUS_META's keys), not api.js's separate generic
// pending/accepted/delivered mapping used by the Deliveries page.
//
// The "Assigned" tab originally matched only `converted_to_consignment` — the
// ONLY other status this file's author had actually observed live at the
// time (see ROW_STATUS_META's comment). Confirmed live now (booking status
// breakdown logged from a real assign-miler call): a booking moves
// pending_pickup -> miler_assigned immediately on assignment, not straight to
// converted_to_consignment (that only happens later, when the rider marks
// pickup-complete). `miler_assigned` matched nothing in this tab list, so an
// assigned-but-not-yet-picked-up order vanished from every tab.
//
// Deliberately DIFFERENT rule from api.js's BOOKING_STATUS_TO_DELIVERY_STATUS
// (Deliveries page): this Orders page tracks the OPERATOR's own workflow —
// "Assigned" means the operator has picked a rider for it, full stop, the
// moment assign-miler / batch-assign succeeds. The Deliveries page instead
// tracks the RIDER's engagement (its "Accepted" tab specifically means the
// rider accepted, not just that one was assigned) — a deliberately later,
// narrower bar, which is why that page keeps miler_assigned on 'pending'
// while THIS page moves the same status straight to "Assigned". Don't
// "fix" this to match Deliveries again — it was tried and reverted per
// explicit product direction: on Orders, Assigned = operator action; on
// Deliveries, Accepted = rider action. `statuses` (plural) is the actual
// match set; each tab's singular `status` stays the tab's
// primary/representative value for `currentStatus`, the React key, and the
// few `currentStatus === 'pending_pickup'` checks elsewhere in this file.
const ORDERS_STATUS_TABS = [
{ idx: 0, status: 'pending_pickup', label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
{ idx: 1, status: 'converted_to_consignment', label: 'Assigned', color: '#6366f1', icon: MdCheckCircle },
{ idx: 2, status: 'delivered', label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
{ idx: 3, status: 'cancelled', label: 'Cancelled', color: '#ef4444', icon: MdCancel }
{ idx: 0, status: 'pending_pickup', statuses: ['pending_pickup'], label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
{
idx: 1,
status: 'converted_to_consignment',
statuses: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'],
label: 'Assigned',
color: '#6366f1',
icon: MdCheckCircle
},
{ idx: 2, status: 'delivered', statuses: ['delivered'], label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
{ idx: 3, status: 'cancelled', statuses: ['cancelled'], label: 'Cancelled', color: '#ef4444', icon: MdCancel }
];
// Haversine straight-line distance in km — GET /admin/bookings has no `kms`
@@ -360,10 +398,30 @@ const Orders = () => {
return counts;
}, [allBookings]);
// Diagnostic: a newly-created booking not showing on the Pending tab could
// mean either (a) Doormile's background auto-assignment (see
// express-console-api.md — fires after commit, 5 retries 2 min apart) has
// already moved it out of pending_pickup by the time the operator looks,
// which is expected behavior, not a bug, or (b) a brand-new booking's
// actual status string doesn't match any of the tabs at all (an unmapped
// value BOOKING_STATUS_TO_DELIVERY_STATUS in api.js has never observed).
// This makes that distinguishable from the console instead of guessing.
useEffect(() => {
logger.debug('orders.js: booking status breakdown', statusCounts);
}, [statusCounts]);
// The active tab's full status match set (see ORDERS_STATUS_TABS' comment) —
// falls back to [currentStatus] so this still works if currentStatus is
// ever set to something outside the tab list.
const activeTabStatuses = useMemo(
() => ORDERS_STATUS_TABS.find((t) => t.status === currentStatus)?.statuses || [currentStatus],
[currentStatus]
);
const filteredRows = useMemo(() => {
const q = debouncedSearch.trim().toLowerCase();
return allBookings.filter((b) => {
if (String(b.status || '').toLowerCase() !== currentStatus) return false;
if (!activeTabStatuses.includes(String(b.status || '').toLowerCase())) return false;
if (!q) return true;
return (
b.bookingno?.toLowerCase().includes(q) ||
@@ -372,7 +430,7 @@ const Orders = () => {
b.notes?.toLowerCase().includes(q)
);
});
}, [allBookings, currentStatus, debouncedSearch]);
}, [allBookings, activeTabStatuses, debouncedSearch]);
// "Load more" now reveals more of the already-fetched, already-filtered
// set instead of requesting another server page (the bulk fetch above
@@ -473,9 +531,12 @@ const Orders = () => {
return {
...val,
deliveryid: 0,
deliverydate: dayjs(val.serviceoptions?.[0]?.estimateddeliveryat || val.createdat)
.utc()
.format('YYYY-MM-DD HH:mm:ss'),
// Doormile timestamps are naive IST wall-clock (express-console-api.md) —
// .utc() was reinterpreting that as local time and shifting it back
// 5:30, sending the solver a wrong delivery deadline. Parse bare,
// matching assigntime right below (and deliveries.js's own fix for
// the same bug class in its date-display cells).
deliverydate: dayjs(val.serviceoptions?.[0]?.estimateddeliveryat || val.createdat).format('YYYY-MM-DD HH:mm:ss'),
assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
orderstatus: 'pending',
orderamount: charge,
@@ -498,6 +559,70 @@ const Orders = () => {
});
};
// Doormile-native alternative to the external solver above — assigns real
// milers directly via POST /hub/bookings/batch-assign (Redis GEO + AI
// scoring), no external solver rider pool involved, so there's no chance of
// the stale-id mismatch that made the "unverified rider" flow on
// /dispatch/preview necessary for the solver path. Commits server-side in
// this one call — no preview/reconcile step exists for this path because
// there is nothing to reconcile. Sequencing (/optimization/doormile/sequence)
// isn't deployed yet, so riders with more than one stop come back
// unsequenced (step: 0) — an accepted, visible trade-off for this opt-in
// "Quick Assign" button; operators who need solver-quality sequencing keep
// using "Assign Orders" above. See doormile-flow.md and the plan this was
// built from.
const quickAssignMutation = useMutation({
mutationFn: () => batchAssignBookings(selectedOrders.map((o) => o.bookingid)),
onSuccess: async (data) => {
const results = data?.results || data?.data?.results || [];
const assignedCount = data?.assigned ?? data?.data?.assigned ?? results.filter((r) => r.assigned).length;
const skipped = results.filter((r) => !r.assigned);
enqueueSnackbar(`Quick Assign: ${assignedCount} assigned${skipped.length ? `, ${skipped.length} skipped` : ''}`, {
variant: skipped.length ? 'warning' : 'success',
autoHideDuration: 3000,
anchorOrigin: { vertical: 'top', horizontal: 'right' }
});
if (skipped.length) {
logger.debug(
'quickAssignMutation: skipped bookings',
skipped.map((r) => r.bookingid)
);
}
// batch-assign returns mileruserid per result — resolve to
// milerprofileid (what notifyRider/notify actually needs) the same way
// finalCreatedeliveries does, then notify each assigned rider.
try {
const milers = (await getMilers()) || [];
const lookup = buildMilerLookup(milers);
const notifiedProfileIds = new Set();
results
.filter((r) => r.assigned)
.forEach((r) => {
const rider = lookup.byUserId.get(String(r.mileruserid));
if (rider?.milerprofileid && !notifiedProfileIds.has(rider.milerprofileid)) {
notifiedProfileIds.add(rider.milerprofileid);
notifyRider(rider.milerprofileid).catch((err) => logger.error('quickAssignMutation: notifyRider failed', err.message));
}
});
} catch (err) {
logger.error('quickAssignMutation: rider notify resolution failed', err.message);
}
refetchOrders();
setSelectedOrders([]);
},
onError: (error) => {
enqueueSnackbar(error.message, { variant: 'error', autoHideDuration: 4000 });
}
});
const handleQuickAssign = () => {
if (selectedOrders.length === 0) return;
quickAssignMutation.mutate();
};
// KPI tile definitions.
const kpiCards = [
{
@@ -561,6 +686,39 @@ const Orders = () => {
</Button>
)}
{selectedOrders.length > 0 && currentStatus === 'pending_pickup' && (
<Tooltip title="Assigns real Doormile riders directly (no external solver). Stops aren't sequenced yet — best for single-stop or same-hub batches.">
<Button
variant="contained"
onClick={handleQuickAssign}
disabled={quickAssignMutation.isPending}
sx={{
position: 'fixed',
bottom: 32,
right: 24 + 200,
bgcolor: BRAND_LIGHT,
color: '#fff',
px: 3,
py: 1.5,
borderRadius: 999,
fontWeight: 850,
boxShadow: `0 10px 30px ${ring(BRAND_LIGHT)}`,
zIndex: 1000,
textTransform: 'none',
fontSize: '14px',
gap: 1,
'&:hover': {
bgcolor: BRAND,
transform: 'translateY(-2px)'
}
}}
startIcon={<MdFlashOn size={18} />}
>
Quick Assign
</Button>
</Tooltip>
)}
{/* ============================================= || Header (compact) || ============================================= */}
<Paper
elevation={0}
@@ -658,7 +816,7 @@ const Orders = () => {
{ORDERS_STATUS_TABS.map((t) => {
const Icon = t.icon;
const active = tabvalue === t.idx;
const count = statusCounts[t.status] ?? 0;
const count = t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0);
return (
<Box
key={t.status}
@@ -903,7 +1061,7 @@ const Orders = () => {
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.125 }}>
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
{(() => {
const dateObj = dayjs(row.createdat);
const dateObj = parseDoormileTimestamp(row.createdat);
return (
<>
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
@@ -1012,28 +1170,6 @@ const Orders = () => {
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Track">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setTrackBookingId(row.bookingid);
}}
sx={{
bgcolor: tint('#0ea5e9'),
border: `1px solid ${edge('#0ea5e9')}`,
color: '#0ea5e9',
borderRadius: 999,
p: 0.75,
'&:hover': {
bgcolor: soft('#0ea5e9'),
borderColor: '#0ea5e9'
}
}}
>
<MdMyLocation size={14} />
</IconButton>
</Tooltip>
{currentStatus === 'pending_pickup' && isCancellable && (
<Tooltip title="Cancel Order">
<IconButton

View File

@@ -673,7 +673,7 @@ export default function OrdersDetails() {
<Grid item xs={12} sm={6} md={3}>
<Autocomplete
options={locationlist || []}
getOptionLabel={(option) => (option ? `${option.locationname} (${option.suburb})` : '')}
getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')}
value={locationValue}
PaperComponent={SoftPaper}
onOpen={(event) => {
@@ -725,7 +725,10 @@ export default function OrdersDetails() {
<Autocomplete
options={ridersList || []}
value={riderValue}
getOptionLabel={(option) => `${option.firstname} ${option.lastname}`}
getOptionLabel={(option) => {
const name = option?.displayname || option?.authname || '';
return option?.contactno ? `${name} (${option.contactno})` : name;
}}
PaperComponent={SoftPaper}
onOpen={() => {
if (!appId) {

View File

@@ -0,0 +1,22 @@
import dayjs from 'dayjs';
// Doormile timestamps are IST (Asia/Kolkata) wall-clock stored in Postgres
// `timestamp without time zone` columns (express-console-api.md, "Conventions
// across every endpoint"). Some responses come back with a trailing Z/offset
// anyway — a known Go+pgx footgun where a naive DB timestamp loads into
// time.Time under UTC location and gets marshaled with a false "Z" suffix.
// dayjs treats a Z-suffixed string as a real UTC instant and converts it to
// the browser's local time on display/bucketing, adding a spurious +5:30 on
// top of digits that were already correct IST.
//
// Stripping any trailing zone marker before parsing makes both cases (truly
// naive, or naive-with-false-Z) render/bucket identically as the raw
// wall-clock digits. Shared by orders.js (row date cells), deliveries.js and
// Dispatch.js (`assigntime`-based batch bucketing — CLAUDE.md requires both
// pages agree on which batch a row belongs to, so both must use the same
// parse).
export const parseDoormileTimestamp = (raw) => {
if (!raw) return dayjs(null);
const stripped = String(raw).replace(/(Z|[+-]\d{2}:?\d{2})$/, '');
return dayjs(stripped);
};