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 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(() => { useEffect(() => {
// Zones are derived from GET /admin/hubs (the new API has no dedicated // Zones are derived from GET /admin/hubs (the new API has no dedicated
@@ -35,7 +40,7 @@ const LocationAutocomplete = forwardRef(
const fetchLocations = async () => { const fetchLocations = async () => {
try { try {
const updatedLocations = await fetchAppLocations(); const updatedLocations = await fetchAppLocations();
localStorage.setItem('applocations', JSON.stringify(updatedLocations)); localStorage.setItem(applocationsCacheKey, JSON.stringify(updatedLocations));
setLocations(updatedLocations); setLocations(updatedLocations);
} catch (err) { } catch (err) {
console.error('Error fetching locations in LocationAutocomplete:', err); console.error('Error fetching locations in LocationAutocomplete:', err);
@@ -45,7 +50,7 @@ const LocationAutocomplete = forwardRef(
if (locations.length === 0) { if (locations.length === 0) {
fetchLocations(); fetchLocations();
} }
}, [locations.length]); }, [locations.length, applocationsCacheKey]);
// Helpers (only used by pill variant) — match the deliveries page's // Helpers (only used by pill variant) — match the deliveries page's
// token shorthand so the same opacity ramp is applied here. // 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/ // lifecycle keys (pending/accepted/arrived/picked/active/skipped/delivered/
// cancelled) for its tabs, chip counts, and row badges. The new booking // cancelled) for its tabs, chip counts, and row badges. The new booking
// status enum uses different strings entirely (Pending_Pickup, // status enum uses different strings entirely (Pending_Pickup,
// Converted_To_Consignment, Out_for_Delivery, ...) — only Pending_Pickup, // Miler_Assigned, Pickup_Scheduled, Converted_To_Consignment, Out_for_Delivery,
// Converted_To_Consignment (from a real sample) and Out_for_Delivery (named // ...) — Pending_Pickup, Converted_To_Consignment, Out_for_Delivery (named in
// in express-console-api.md's CityGate note) are confirmed; the rest of this // express-console-api.md's CityGate note), and now Miler_Assigned /
// mapping is a best-effort guess. Unmapped statuses pass through lowercased, // Pickup_Scheduled (confirmed live via a real booking-status breakdown logged
// which the page's own fallback renders as an "unknown" badge rather than // right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS
// crashing. There's no confirmed equivalent for arrived/picked/skipped at // comment) are confirmed; the rest of this mapping is still a best-effort
// all, so those tabs will show a 0 count until the real enum is confirmed. // 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 = { const BOOKING_STATUS_TO_DELIVERY_STATUS = {
pending_pickup: 'pending', pending_pickup: 'pending',
miler_assigned: 'pending',
pickup_scheduled: 'accepted',
converted_to_consignment: 'accepted', converted_to_consignment: 'accepted',
out_for_delivery: 'active', out_for_delivery: 'active',
delivered: 'delivered', delivered: 'delivered',
@@ -101,6 +119,19 @@ export const getRiderPeriodicLogs = async (userid) => {
// ==============================|| fetchAppLocations (zone/location picker) ||============================== // // ==============================|| fetchAppLocations (zone/location picker) ||============================== //
// The new API has no "zones" resource — applocationid only exists as a field // 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. // 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 () => { export const fetchAppLocations = async () => {
try { try {
const hubs = await getHubs(); const hubs = await getHubs();
@@ -110,7 +141,36 @@ export const fetchAppLocations = async () => {
seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname }); 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) { } catch (err) {
OpenToast(err.message, 'error', 2000); OpenToast(err.message, 'error', 2000);
return [{ locationname: 'All', applocationid: 0 }]; return [{ locationname: 'All', applocationid: 0 }];
@@ -175,10 +235,18 @@ export const fetchPaymentType = async () => [];
export const fetchRidersList = async () => { export const fetchRidersList = async () => {
try { try {
const milers = await getMilers(); const milers = await getMilers();
return (milers || []).map((val) => ({ return (milers || []).map((val) => {
...val, const name = val.displayname || val.authname || '';
label: `${val.displayname || val.authname || ''} | ${val.contactno || ''}` 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) { } catch (err) {
// Was `throw`ing after already toasting here — deliveries.js also wires // Was `throw`ing after already toasting here — deliveries.js also wires
// its own onError toast on this same query, so a real failure showed // 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) ||============================== // // ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== //
export const reconcileSteps = async ({ riders }) => { 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 }); 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; 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 // (that one creates a brand-new booking from scratch; wrong here, since
// every order already exists as a Doormile booking pulled from // every order already exists as a Doormile booking pulled from
// GET /admin/bookings). // 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) => { export const finalCreatedeliveries = async (deliveryData) => {
const deliveries = deliveryData.deliveries || []; const deliveries = deliveryData.deliveries || [];
logger.debug(`finalCreatedeliveries: ${deliveries.length} order(s) to assign`); 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 = []; let milers = [];
try { try {
milers = (await getMilers()) || []; milers = (await getMilers()) || [];
@@ -276,47 +383,62 @@ export const finalCreatedeliveries = async (deliveryData) => {
`finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`, `finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`,
milers.map((m) => ({ userid: m.userid, milerprofileid: m.milerprofileid, name: m.displayname || m.authname })) 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 lookup = buildMilerLookup(milers);
const milerByProfileId = new Map(milers.map((m) => [String(m.milerprofileid), m]));
const normName = (s) => String(s || '').trim().toLowerCase(); // Booking id resolution: confirmed live that guessing a single field name
const milerByName = new Map(); // (bookingid first, on the assumption the solver passes unknown fields
milers.forEach((m) => { // through untouched) sends assign-miler a wrong id — a small, sequential-
[m.displayname, m.authname].forEach((n) => { // looking number that 404s. The AI solver is a separate, unverified
const key = normName(n); // service (see this area's CLAUDE.md); there's no reliable way to know
if (key && !milerByName.has(key)) milerByName.set(key, m); // 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( const results = await Promise.allSettled(
deliveries.map(async (d) => { deliveries.map(async (d) => {
// Booking id: the solver is a separate, unverified service (see this const candidates = [d.bookingid, d.orderheaderid, d.deliveryid, d.orderid];
// area's CLAUDE.md) — falls back through every plausible field name const bookingId = candidates.find((c) => c != null && realBookingIds.has(String(c)));
// 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 riderUserId = d.rider_id ?? d.userid; const riderUserId = d.rider_id ?? d.userid;
const riderName = d.rider_name ?? d.rider; const riderName = d.rider_name ?? d.rider;
const matchedVia = milerByUserId.has(String(riderUserId)) const resolved = resolveMilerForOrder(d, lookup);
? 'userid' if (bookingId == null || !resolved) {
: 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 reason = const reason =
bookingId == null 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)`; : `no miler found for rider id ${riderUserId} / name "${riderName}" (checked userid, milerprofileid, name)`;
logger.error(`finalCreatedeliveries: skipping order — ${reason}`); logger.error(`finalCreatedeliveries: skipping order — ${reason}`);
throw new Error(`order ${d.orderid ?? bookingId ?? '?'}: ${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}`); logger.debug(`finalCreatedeliveries: order booking ${bookingId} -> rider ${riderUserId} ("${riderName}") matched via ${matchedVia}`);
try { 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) { } catch (err) {
logger.error( logger.error(
`finalCreatedeliveries: assign-miler failed for booking ${bookingId}`, `finalCreatedeliveries: assign-miler failed for booking ${bookingId}`,
@@ -337,7 +459,12 @@ export const finalCreatedeliveries = async (deliveryData) => {
if (failed.length) { if (failed.length) {
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000); 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 ||============================== // // ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== //
// Also part of the optimiser pipeline (routes.workolik.com / routemate.workolik.com) — untouched. // 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 { return {
rows, 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); export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid);
// ==============================|| changeRiderAPI (deliveries) ||============================== // // ==============================|| changeRiderAPI (deliveries) ||============================== //
// POST /admin/bookings/:id/assign-miler has no documented body — `milerid` as // doormile-flow.md (confirmed current, authoritative) settles this: the body
// the JSON key is a guess (unverified, this is a write endpoint we didn't // is { "mileruserid": <miler's userid> } — the previous guess here (`milerid`
// test live), but the VALUE must be the miler's milerprofileid regardless of // key, `milerprofileid` value) was wrong on both counts. Admin miler
// key name: selectedRider comes straight from getMilers(), whose own .userid // endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the
// field is a different resource (confirmed live via GET /admin/milers/:id). // 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) => export const changeRiderAPI = async (selectedRider, selectedRow) =>
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { milerid: selectedRider.milerprofileid }); assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid });
// ==============================|| updateDeliveryAPI (deliveries) ||============================== // // ==============================|| updateDeliveryAPI (deliveries) ||============================== //
// No amount/notes field exists on PUT /admin/consignments/:id/status — closest // 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 // available write is a status update. Free-text amount/notes edits have no home
// in the new API yet. // 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) ||============================== // // ==============================|| getalltenants (tenants) ||============================== //

View File

@@ -346,6 +346,20 @@ export const assignMilerToBooking = async (id, data) => {
return response.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) => { export const assignVehicleToBooking = async (id, data) => {
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data); const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data);
return response.data; return response.data;

View File

@@ -85,7 +85,7 @@ const soft = (c) => a(c, '18');
const ring = (c) => a(c, '26'); const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55'); const edge = (c) => a(c, '55');
const BRAND = '#662582'; const BRAND = '#C01227';
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van']; const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
const SoftPaper = (props) => ( const SoftPaper = (props) => (
@@ -199,20 +199,30 @@ const ClientsPricing = () => {
const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]); const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]);
const locationMap = useMemo( 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] [locations]
); );
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [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 tenantName = (row) => row.tenantname || tenantMap.get(row.tenantid)?.tenantname || (row.tenantid ? `Tenant #${row.tenantid}` : '—');
const zoneName = (row) => 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 // getallpricing() takes no zone arg (see api.js) — the LocationAutocomplete
// only ever changed the header subtitle text, not which rows were shown. // only ever changed the header subtitle text, not which rows were shown.
// Filter client-side instead; pricing rows already carry applocationid // Filter client-side instead; pricing rows already carry applocationid
// (used by zoneName above), so this is the same real field, just applied. // (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(() => { const rows = useMemo(() => {
if (!debouncedSearch) return zonePricing; if (!debouncedSearch) return zonePricing;

View File

@@ -75,13 +75,15 @@ const Createcustomer = () => {
}); });
// LocationAutocomplete only reports back applocationid/locationname — it // 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 // as a side effect, so pull the coordinates for the address-search bias
// from there rather than duplicating the GET /admin/hubs call. // from there rather than duplicating the GET /admin/hubs call.
useEffect(() => { useEffect(() => {
if (!appId) return; if (!appId) return;
try { 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); const hub = hubs.find((h) => h.applocationid === appId);
if (hub?.latitude) { if (hub?.latitude) {
setAppLocaLat(hub.latitude); setAppLocaLat(hub.latitude);

View File

@@ -9,7 +9,6 @@ import {
Button, Button,
Grid, Grid,
IconButton, IconButton,
InputLabel,
Paper, Paper,
Stack, Stack,
Table, Table,
@@ -24,6 +23,7 @@ import {
useMediaQuery, useMediaQuery,
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { import {
MdPeopleAlt, MdPeopleAlt,
MdOutlinePeopleAlt, MdOutlinePeopleAlt,
@@ -43,6 +43,7 @@ import Loader from 'components/Loader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; 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 { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi'; 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; // all. PATCH /admin/customers/:id is the only mutation the API exposes;
// there is no create/delete here by design ("Tenant-scoped through their // there is no create/delete here by design ("Tenant-scoped through their
// bookings" — a customer only exists once they've ordered through a tenant). // 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 = { const DT = {
@@ -97,6 +105,9 @@ const Customers = () => {
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [editRow, setEditRow] = useState(null); const [editRow, setEditRow] = useState(null);
const [form, setForm] = useState({}); 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 }); const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
@@ -139,6 +150,44 @@ const Customers = () => {
phone: row.phone || '', phone: row.phone || '',
email: row.email || '' 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 ( return (
@@ -188,13 +237,21 @@ const Customers = () => {
background: '#fff' 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}> <Stack direction="row" alignItems="center" spacing={1.25}>
<AccentAvatar color={BRAND} size={32}> <AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={18} /> <MdPeopleAlt size={18} />
</AccentAvatar> </AccentAvatar>
<Stack> <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 Directory
</Typography> </Typography>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}> <Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
@@ -388,7 +445,14 @@ const Customers = () => {
)} )}
</Paper> </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 <DialogTitle
sx={{ sx={{
background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)`, background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)`,
@@ -401,7 +465,9 @@ const Customers = () => {
<FaRegEdit style={{ fontSize: 18 }} /> <FaRegEdit style={{ fontSize: 18 }} />
</Avatar> </Avatar>
<Stack> <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 Customer
</Typography> </Typography>
<Typography sx={{ fontWeight: 800, fontSize: '1.1rem', lineHeight: 1.2, mt: 0.25 }}> <Typography sx={{ fontWeight: 800, fontSize: '1.1rem', lineHeight: 1.2, mt: 0.25 }}>
@@ -411,16 +477,22 @@ const Customers = () => {
</Stack> </Stack>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}> <Grid container spacing={2} sx={{ mt: 2 }}>
<Stack spacing={1}> <Grid item xs={12} sm={6}>
<InputLabel>Name</InputLabel> <Typography sx={{ mb: 1 }}>Customer Name</Typography>
<TextField fullWidth value={form.name || ''} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} /> <TextField
</Stack> variant="outlined"
<Stack spacing={1}> fullWidth
<InputLabel>Phone Number</InputLabel> 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}> <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 <TextField
variant="outlined"
fullWidth fullWidth
value={form.phone || ''} value={form.phone || ''}
inputProps={{ maxLength: 10, inputMode: 'numeric', pattern: '[0-9]*' }} 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 }} /> }} InputProps={{ startAdornment: <MdOutlinePhoneAndroid size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/> />
</Stack> </Stack>
</Stack> </Grid>
<Stack spacing={1}> <Grid item xs={12}>
<InputLabel>Email</InputLabel> <Typography sx={{ mb: 1 }}>Email</Typography>
<TextField <TextField
variant="outlined"
fullWidth fullWidth
type="email" type="email"
value={form.email || ''} value={form.email || ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
InputProps={{ startAdornment: <MdMail size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }} InputProps={{ startAdornment: <MdMail size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/> />
</Stack> </Grid>
</Stack>
<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> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setEditRow(null)} sx={{ color: DT.textSecondary }}> <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'; import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
dayjs.extend(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 { useTheme } from '@mui/material/styles';
import { import {
MdOutlineDateRange, MdOutlineDateRange,
@@ -213,7 +215,12 @@ const getRowBatchId = (row) => {
// so a row that lands in Evening Batch on the dispatch page must also land // 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. // in Evening Batch here — otherwise an operator running in IST/PST/etc.
// sees different totals on the two pages for the same underlying rows. // 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; if (!d.isValid()) return null;
const h = d.hour() + d.minute() / 60; const h = d.hour() + d.minute() / 60;
for (const b of BATCH_OPTIONS) { for (const b of BATCH_OPTIONS) {
@@ -280,6 +287,58 @@ const Deliveries = () => {
const [deliveryamount, setDeliveryamount] = useState(); const [deliveryamount, setDeliveryamount] = useState();
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [currentorder, setCurrentorder] = 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 [deliverylat, setDeliverylat] = useState('');
const [deliverylong, setDeliverylong] = useState(''); const [deliverylong, setDeliverylong] = useState('');
const [currentStatus, setCurrentStatus] = useState('pending'); const [currentStatus, setCurrentStatus] = useState('pending');
@@ -311,7 +370,6 @@ const Deliveries = () => {
// batch matching the current UTC hour (the operator is most likely curious // batch matching the current UTC hour (the operator is most likely curious
// about "now"); never `null` since there's no longer an "All" option. // about "now"); never `null` since there's no longer an "All" option.
const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId); const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId);
const roleid = localStorage.getItem('roleid');
useEffect(() => { useEffect(() => {
setTenantid(0); setTenantid(0);
@@ -534,7 +592,21 @@ const Deliveries = () => {
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined 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(() => { useEffect(() => {
if (countHasNext && !countIsFetchingNext) countFetchNext(); if (countHasNext && !countIsFetchingNext) countFetchNext();
@@ -1097,7 +1169,7 @@ const Deliveries = () => {
<Autocomplete <Autocomplete
disablePortal disablePortal
options={locationlist || []} options={locationlist || []}
getOptionLabel={(option) => `${option.locationname} (${option.suburb})` || ''} getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')}
value={locationValue} value={locationValue}
PaperComponent={SoftPaper} PaperComponent={SoftPaper}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }} sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
@@ -1144,7 +1216,10 @@ const Deliveries = () => {
disabled={riderListIsLoading} disabled={riderListIsLoading}
options={ridersList} options={ridersList}
PaperComponent={SoftPaper} 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 } }} sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
onChange={(e, value, reason) => { onChange={(e, value, reason) => {
if (reason === 'clear') setRiderid(0); if (reason === 'clear') setRiderid(0);
@@ -1494,7 +1569,7 @@ const Deliveries = () => {
<MobileFieldGrid> <MobileFieldGrid>
<MobileField label="Order / Location" full> <MobileField label="Order / Location" full>
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap> <Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
{`${row.locationname}-(${row.locationsuburb})`} {row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}> <Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.orderid} · {row.deliveryid} {row.orderid} · {row.deliveryid}
@@ -1546,8 +1621,8 @@ const Deliveries = () => {
</MobileField> </MobileField>
<MobileField label="Kms · plan / act"> <MobileField label="Kms · plan / act">
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
<Box sx={chipSx('#ef4444')}>{row.kms || 0} km</Box> <Box sx={chipSx('#ef4444')}>{Number(row.kms || 0).toFixed(2)} km</Box>
<Box sx={chipSx('#10b981')}>{row.cumulativekms || 0} km</Box> <Box sx={chipSx('#10b981')}>{Number(row.cumulativekms || 0).toFixed(2)} km</Box>
</Stack> </Stack>
</MobileField> </MobileField>
<MobileField label="Amount · chg / amt"> <MobileField label="Amount · chg / amt">
@@ -1833,7 +1908,7 @@ const Deliveries = () => {
<TableCell align="left"> <TableCell align="left">
<Tooltip title="Location Name-Suburb" placement="top"> <Tooltip title="Location Name-Suburb" placement="top">
<Typography variant="subtitle1" noWrap> <Typography variant="subtitle1" noWrap>
{`${row.locationname}-(${row.locationsuburb})`} {row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
</Typography> </Typography>
</Tooltip> </Tooltip>
@@ -1844,12 +1919,18 @@ const Deliveries = () => {
{row.orderid} {row.orderid}
</Typography> </Typography>
</Tooltip> </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"> <Tooltip title="Ordered date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}> <Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.orderdate).utc().format('DD/MM/YYYY')} {dayjs(row.orderdate).format('DD/MM/YYYY')}
</Typography> </Typography>
<Typography noWrap sx={{ fontSize: '11px' }}> <Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.orderdate).utc().format('hh:mm A')} {dayjs(row.orderdate).format('hh:mm A')}
</Typography> </Typography>
</Tooltip> </Tooltip>
</Stack> </Stack>
@@ -1862,44 +1943,62 @@ const Deliveries = () => {
</Tooltip> </Tooltip>
<Tooltip title="Delivery date" placement="top"> <Tooltip title="Delivery date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}> <Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')} {dayjs(row.deliverydate).format('DD/MM/YYYY')}
</Typography> </Typography>
<Typography noWrap sx={{ fontSize: '11px' }}> <Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.deliverydate).utc().format('hh:mm A')} {dayjs(row.deliverydate).format('hh:mm A')}
</Typography> </Typography>
</Tooltip> </Tooltip>
</Stack> </Stack>
</Stack> </Stack>
</TableCell> </TableCell>
{/* pickup */} {/* pickup — capped to the same maxWidth as drop below, with CSS
<TableCell align="left"> ellipsis truncation, so one long address can't stretch this
<Stack> column wider than its neighbour. row.pickuplocation is just
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}> 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} {row.pickupcustomer}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}> <Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.pickupcontactno} {row.pickupcontactno}
</Typography> </Typography>
<Tooltip title={row.Pickupaddress} sx={{ whiteSpace: 'nowrap' }}> <Tooltip title={row.Pickupaddress || ''}>
<Typography variant="caption" sx={{ color: DT.textMuted }}> <Typography
{row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : '—')} variant="caption"
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
>
{row.pickuplocation || row.Pickupaddress || '—'}
</Typography> </Typography>
</Tooltip> </Tooltip>
</Stack> </Stack>
</TableCell> </TableCell>
{/* drop */} {/* drop — same maxWidth/truncation treatment as pickup above. */}
<TableCell align="left"> <TableCell align="left" sx={{ maxWidth: 190 }}>
<Stack> <Stack sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}> <Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{row.deliverycustomer} {row.deliverycustomer}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}> <Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.deliverycontactno} {row.deliverycontactno}
</Typography> </Typography>
<Tooltip title={row.deliveryaddress}> <Tooltip title={row.deliveryaddress || ''}>
<Typography variant="caption" sx={{ color: DT.textMuted, whiteSpace: 'nowrap' }}> <Typography
{row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : '—')} variant="caption"
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
>
{row.deliverylocation || row.deliveryaddress || '—'}
</Typography> </Typography>
</Tooltip> </Tooltip>
</Stack> </Stack>
@@ -1973,7 +2072,7 @@ const Deliveries = () => {
minWidth: 75 minWidth: 75
}} }}
> >
{row.kms || 0} km {Number(row.kms || 0).toFixed(2)} km
</Box> </Box>
</Tooltip> </Tooltip>
<Tooltip title="Actual KMS" placement="top"> <Tooltip title="Actual KMS" placement="top">
@@ -1994,7 +2093,7 @@ const Deliveries = () => {
minWidth: 75 minWidth: 75
}} }}
> >
{row.cumulativekms || 0} km {Number(row.cumulativekms || 0).toFixed(2)} km
</Box> </Box>
</Tooltip> </Tooltip>
</Stack> </Stack>
@@ -2371,27 +2470,29 @@ const Deliveries = () => {
Change Rider Change Rider
</MenuItem> </MenuItem>
)} )}
{(roleid == 1 || roleid == 2) && ( {/* Was gated to roleid 1/2 (staff-only), matching the reference app's
<MenuItem identical gate — opened to every login per explicit decision, so
onClick={() => { tenant operators can reach the same Update Status fields staff
setKms(selectedRow.kms); already could. */}
setCumulativeKms(selectedRow.cumulativekms); <MenuItem
setDeliverylat(selectedRow.droplat); onClick={() => {
setDeliverylong(selectedRow.droplon); setKms(selectedRow.kms);
setNotes(selectedRow.notes); setCumulativeKms(selectedRow.cumulativekms);
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount); setDeliverylat(selectedRow.droplat);
setUpdateStatus(selectedRow.orderstatus || 'delivered'); setDeliverylong(selectedRow.droplon);
setCurrentorder(selectedRow); setNotes(selectedRow.notes);
setDialogopen(true); setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
handleMenuClose(); setUpdateStatus(selectedRow.orderstatus || 'delivered');
}} setCurrentorder(selectedRow);
> setDialogopen(true);
<AccentAvatar color="#10b981" size={22}> handleMenuClose();
<MdCheckCircle size={13} /> }}
</AccentAvatar> >
Update Status <AccentAvatar color="#10b981" size={22}>
</MenuItem> <MdCheckCircle size={13} />
)} </AccentAvatar>
Update Status
</MenuItem>
{selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && ( {selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && (
<MenuItem <MenuItem
sx={{ color: '#ef4444 !important' }} sx={{ color: '#ef4444 !important' }}
@@ -2539,7 +2640,10 @@ const Deliveries = () => {
fullWidth fullWidth
options={ridersList} options={ridersList}
PaperComponent={SoftPaper} 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) => { onChange={(e, value) => {
setSelectedRider(value); setSelectedRider(value);
logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None'); logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None');
@@ -2922,6 +3026,7 @@ const Deliveries = () => {
} else { } else {
updateDeliveryMutation.mutate({ updateDeliveryMutation.mutate({
deliveryid: currentorder.deliveryid, deliveryid: currentorder.deliveryid,
consignmentid: currentorder.consignmentid,
orderheaderid: currentorder.orderheaderid, orderheaderid: currentorder.orderheaderid,
orderstatus: updateStatus, orderstatus: updateStatus,
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'), deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),

View File

@@ -58,6 +58,7 @@ import ProfitabilitySection from './ProfitabilitySection';
import ActiveSection from './ActiveSection'; import ActiveSection from './ActiveSection';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api'; import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
import { getConsignmentLogs } from 'pages/api/doormileApi'; import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { import {
STATUS_STYLES, STATUS_STYLES,
getStatusStyle, getStatusStyle,
@@ -66,7 +67,10 @@ import {
STEP_PALETTE, STEP_PALETTE,
stepColor, stepColor,
isActiveDelivery, isActiveDelivery,
getActiveOrder getActiveOrder,
haversineKm,
polylineLengthKm,
kalmanSmoothGps
} from './dispatchShared'; } from './dispatchShared';
import CompareDataPanel from './CompareDataPanel'; import CompareDataPanel from './CompareDataPanel';
import './Dispatch.css'; import './Dispatch.css';
@@ -224,7 +228,12 @@ const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
const str = String(t).trim(); const str = String(t).trim();
// Skip bare date strings — no time component, would always parse to midnight. // Skip bare date strings — no time component, would always parse to midnight.
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; 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; if (!d.isValid()) return null;
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which // 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 // starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
@@ -273,274 +282,9 @@ function MapAutoResize({ trigger }) {
return null; return null;
} }
// Haversine distance between two [lat, lng] points in kilometers. Good to // haversineKm/polylineLengthKm/kalmanSmoothGps moved to dispatchShared.js so
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped // deliveries.js's Update Status dialog can compute the same real, GPS-based
// polyline so the Compare delta panel can show "actual km" without depending // Actual KMs figure instead of leaving that field permanently blank.
// 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
}));
}
// Splits a routed OSRM polyline into per-step segments by finding the // Splits a routed OSRM polyline into per-step segments by finding the
// polyline index closest to each drop waypoint. Returns an array of // polyline index closest to each drop waypoint. Returns an array of
@@ -594,7 +338,7 @@ const formatTimeOnly = (t) => {
if (!t) return null; if (!t) return null;
const d = dayjs(t); const d = dayjs(t);
if (!d.isValid()) return String(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. // 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' } { 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) => { const analysisFormatNum = (v) => {
if (v == null) return '—'; if (v == null) return '—';
if (typeof v === 'number') return v.toLocaleString('en-IN'); 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'); if (Number.isFinite(n)) return n.toLocaleString('en-IN');
return String(v); 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 // 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 // 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 // rider timelines on the Analysis page — the API ships those fields as bare
@@ -3507,13 +3235,6 @@ const Dispatch = ({
return routes; return routes;
}; };
const toggleRider = (rid) => {
const newActive = new Set(activeRiders);
if (newActive.has(rid)) newActive.delete(rid);
else newActive.add(rid);
setActiveRiders(newActive);
};
return ( return (
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}> <div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
{!embedded && ( {!embedded && (
@@ -4617,7 +4338,7 @@ const Dispatch = ({
<div className="zone-order-stats"> <div className="zone-order-stats">
<span className="zone-order-chip" title="Distance"> <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>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit"> <span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)} <Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -4766,7 +4487,7 @@ const Dispatch = ({
<div className="zone-order-stats"> <div className="zone-order-stats">
<span className="zone-order-chip" title="Distance"> <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>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit"> <span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)} <Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -4913,7 +4634,7 @@ const Dispatch = ({
<div className="zone-order-stats"> <div className="zone-order-stats">
<span className="zone-order-chip" title="Distance"> <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>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit"> <span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)} <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' /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" /> <ZoomControl position="bottomright" />
{compareOpen && <CaptureMap targetRef={leftMapRef} />} {compareOpen && <CaptureMap targetRef={leftMapRef} />}
{compareOpen && (
<CompareMapClickUnpin
onUnpin={() => {
pinnedPopupsRef.current.clear();
setCenterPopupOrder(null);
}}
/>
)}
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} /> <MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} /> <MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
{kitchens {kitchens

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import logger from '../../../utils/logger';
import { import {
Autocomplete, Autocomplete,
Backdrop, Backdrop,
@@ -26,15 +27,17 @@ import dayjs from 'dayjs';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { HiOutlineArrowLeft } from 'react-icons/hi'; import { HiOutlineArrowLeft } from 'react-icons/hi';
import { IoReload } from 'react-icons/io5'; import { IoReload } from 'react-icons/io5';
import { MdTwoWheeler, MdSwapHoriz } from 'react-icons/md'; import { MdTwoWheeler, MdSwapHoriz, MdWarning } from 'react-icons/md';
import { import {
buildMilerLookup,
createAutomationDeliveries, createAutomationDeliveries,
createOptimisationDeliveries, createOptimisationDeliveries,
fetchRidersList, fetchRidersList,
finalCreatedeliveries, finalCreatedeliveries,
notifyRider, notifyRider,
reconcileSteps reconcileSteps,
resolveMilerForOrder
} from '../../api/api'; } from '../../api/api';
import { OpenToast } from 'components/third-party/OpenToast'; import { OpenToast } from 'components/third-party/OpenToast';
import CSVExport from 'components/third-party/ReactTable'; import CSVExport from 'components/third-party/ReactTable';
@@ -152,6 +155,14 @@ const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName })
movedOrder = r.orders[oi]; movedOrder = r.orders[oi];
r.orders.splice(oi, 1); r.orders.splice(oi, 1);
homeZoneIdx = zi; 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 { } else {
next.zones = [ next.zones = [
{ {
@@ -329,19 +349,17 @@ const Preview = () => {
const autoRiders = stateData.autoRiders || []; const autoRiders = stateData.autoRiders || [];
const absentRidersPayload = stateData.absentRidersPayload || []; const absentRidersPayload = stateData.absentRidersPayload || [];
const appId = useMemo(() => { // fetchRidersList() takes no params — GET /admin/milers is tenant-scoped
if (stateData.appId) return stateData.appId; // server-side from the auth token, not by appId. Gating this query on
if (typeof window !== 'undefined') { // `!!appId` was wrong: orders.js (the entry point into this page) always
const v = localStorage.getItem('applocationid'); // navigates here with a hardcoded appId of 0 (it has no zone picker at
return v ? Number(v) : 0; // all), which made this query permanently disabled unless a stale
} // `applocationid` happened to be cached in localStorage from a previous
return 0; // Dispatch.js visit — the Change Rider dropdown showed "no riders" for
}, [stateData.appId]); // anyone who reached this page the normal way.
const { data: ridersList } = useQuery({ const { data: ridersList } = useQuery({
queryKey: ['ridersList', appId], queryKey: ['ridersList'],
queryFn: fetchRidersList, queryFn: fetchRidersList,
enabled: !!appId,
staleTime: 5 * 60 * 1000 staleTime: 5 * 60 * 1000
}); });
@@ -361,6 +379,24 @@ const Preview = () => {
return []; return [];
}, [reconcileRiders, dispatchPreviewData]); }, [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(() => { useEffect(() => {
const filtered = finaldeliveryList.map((item) => ({ const filtered = finaldeliveryList.map((item) => ({
zone_name: item.zone_name, zone_name: item.zone_name,
@@ -387,7 +423,17 @@ const Preview = () => {
const notifyRiderMutation = useMutation({ const notifyRiderMutation = useMutation({
mutationFn: notifyRider, mutationFn: notifyRider,
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000), 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({ const createDeliveryMutation = useMutation({
@@ -415,26 +461,23 @@ const Preview = () => {
// database, which neither the Orders "pending" list nor the Deliveries // database, which neither the Orders "pending" list nor the Deliveries
// "dispatched" filter ever read (both come from GET /admin/bookings). // "dispatched" filter ever read (both come from GET /admin/bookings).
mutationFn: finalCreatedeliveries, mutationFn: finalCreatedeliveries,
onSuccess: () => { onSuccess: (data) => {
OpenToast('Delivery Created Successfully', 'success', 2000); OpenToast('Delivery Created Successfully', 'success', 2000);
setIsLoading(false); setIsLoading(false);
// stateData.rider (a single rider forwarded via navigate() from the // stateData.rider (a single rider forwarded via navigate() from the
// Orders page) is never actually populated in the real flow — that // Orders page) is never actually populated in the real flow — that
// page's navigate() call doesn't include a `rider` key at all — so // 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 // this was a permanent no-op and no rider ever got notified after
// assignment. Notify every rider actually present in the committed // assignment. Notify every rider finalCreatedeliveries actually
// list instead. notifyRider expects a milerprofileid (not an FCM // resolved and assigned (data.resolvedMilerProfileIds — real
// token — the server looks the device up itself); rider_id/userid // milerprofileids from GET /admin/milers, deduped there). Previously
// here is the id this page already treats as canonical throughout // this notified using order.rider_id/userid directly, which is the
// (see flattenRiders/moveOrderInPreviewData above) since it's the // solver's own internal rider numbering — confirmed live to NOT be a
// only rider identifier the solver echoes back — unconfirmed whether // real Doormile userid or milerprofileid (see api.js's
// that's actually a milerprofileid by the time it reaches here. // finalCreatedeliveries) — so every notification went out with a
const notifiedRiderIds = new Set(); // bogus id and likely silently failed server-side.
finaldeliveryList.forEach((order) => { (data?.resolvedMilerProfileIds || []).forEach((milerprofileid) => {
const riderId = order.rider_id ?? order.userid; notifyRiderMutation.mutate(milerprofileid);
if (riderId == null || notifiedRiderIds.has(String(riderId))) return;
notifiedRiderIds.add(String(riderId));
notifyRiderMutation.mutate(riderId);
}); });
navigate('/doormile/deliveries'); navigate('/doormile/deliveries');
}, },
@@ -446,9 +489,13 @@ const Preview = () => {
}); });
const reconcileMutation = useMutation({ const reconcileMutation = useMutation({
mutationFn: reconcileSteps, mutationFn: (payload) => {
logger.debug('reconcile: sending payload', payload);
return reconcileSteps(payload);
},
onMutate: () => setReconcileLoading(true), onMutate: () => setReconcileLoading(true),
onSuccess: (data) => { onSuccess: (data) => {
logger.debug('reconcile: response', data);
if (Array.isArray(data?.riders)) { if (Array.isArray(data?.riders)) {
// Merge: applyReconcileResponse replaces orders for riders present // Merge: applyReconcileResponse replaces orders for riders present
// in the response and leaves the rest of the cache untouched. // in the response and leaves the rest of the cache untouched.
@@ -459,14 +506,17 @@ const Preview = () => {
setDirtyRiderIds((prev) => { setDirtyRiderIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
data.riders.forEach((r) => next.delete(String(r.rider_id))); data.riders.forEach((r) => next.delete(String(r.rider_id)));
logger.debug('reconcile: dirtyRiderIds after clearing reconciled riders', [...next]);
return next; return next;
}); });
OpenToast('Steps reconciled — preview updated', 'success', 2000); OpenToast('Steps reconciled — preview updated', 'success', 2000);
} else { } 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); OpenToast('Reconcile returned no rider data', 'warning', 3000);
} }
}, },
onError: (error) => { onError: (error) => {
logger.error('reconcile: request failed', error?.response?.status, error?.response?.data || error?.message);
OpenToast(error.message || 'Reconcile failed', 'error', 4000); OpenToast(error.message || 'Reconcile failed', 'error', 4000);
}, },
onSettled: () => setReconcileLoading(false) onSettled: () => setReconcileLoading(false)
@@ -513,11 +563,18 @@ const Preview = () => {
OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000); OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000);
return; 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); setIsLoading(true);
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList }); createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
}; };
const handleReconcile = () => { const handleReconcile = () => {
logger.debug('handleReconcile: dirtyRiderIds', [...dirtyRiderIds], 'reconcileRiders ids', reconcileRiders.map((r) => r.rider_id));
if (!reconcileRiders.length) { if (!reconcileRiders.length) {
OpenToast('No riders to reconcile', 'warning', 3000); OpenToast('No riders to reconcile', 'warning', 3000);
return; return;
@@ -529,6 +586,10 @@ const Preview = () => {
dirtyRiderIds.has(String(r.rider_id)) dirtyRiderIds.has(String(r.rider_id))
); );
if (!dirty.length) { 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); OpenToast('No edits to reconcile', 'info', 2500);
return; return;
} }
@@ -559,21 +620,42 @@ const Preview = () => {
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() || `${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
`Rider ${newRiderId}`; `Rider ${newRiderId}`;
setDispatchPreviewData((prev) => const moved = moveOrderInPreviewData(dispatchPreviewData, {
moveOrderInPreviewData(prev, { orderId: selectedOrder.orderid,
orderId: selectedOrder.orderid, oldRiderId: selectedOldRiderId,
oldRiderId: selectedOldRiderId, newRiderId,
newRiderId, newRiderName
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 // 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 // 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) => { setDirtyRiderIds((prev) => {
const next = new Set(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)); if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
logger.debug('confirmChangeRider: dirtyRiderIds after change', [...next]);
return next; return next;
}); });
setHasReconciled(false); setHasReconciled(false);
@@ -742,6 +824,7 @@ const Preview = () => {
{r.orders.map((o, idx) => { {r.orders.map((o, idx) => {
const stepNum = o.step ?? idx + 1; const stepNum = o.step ?? idx + 1;
const color = stepColor(Number(stepNum) - 1); const color = stepColor(Number(stepNum) - 1);
const isUnverified = unverifiedOrderIds.has(String(o.orderid));
return ( return (
<Tooltip <Tooltip
key={`${o.orderid}-${idx}`} key={`${o.orderid}-${idx}`}
@@ -749,17 +832,20 @@ const Preview = () => {
<Box> <Box>
<div>Order #{o.orderid}</div> <div>Order #{o.orderid}</div>
<div>{o.deliveryaddress || o.deliverysuburb || ''}</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>
} }
> >
<Box <Box
onClick={() => openChangeRider(r, o)} onClick={() => openChangeRider(r, o)}
sx={{ sx={{
position: 'relative',
width: 36, width: 36,
height: 36, height: 36,
borderRadius: '50%', borderRadius: '50%',
bgcolor: color, bgcolor: isUnverified ? '#ef4444' : color,
color: '#fff', color: '#fff',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
@@ -767,13 +853,27 @@ const Preview = () => {
fontWeight: 800, fontWeight: 800,
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
boxShadow: boxShadow: isUnverified
'0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)', ? '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', transition: 'transform 0.15s',
'&:hover': { transform: 'scale(1.08)' } '&:hover': { transform: 'scale(1.08)' }
}} }}
> >
{stepNum} {stepNum}
{isUnverified && (
<MdWarning
size={14}
style={{
position: 'absolute',
top: -5,
right: -5,
color: '#ef4444',
background: '#fff',
borderRadius: '50%'
}}
/>
)}
</Box> </Box>
</Tooltip> </Tooltip>
); );
@@ -823,12 +923,20 @@ const Preview = () => {
> >
Back Back
</Button> </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}> <span style={isMobile ? { width: '100%' } : undefined}>
<Button <Button
variant="contained" variant="contained"
fullWidth={isMobile} fullWidth={isMobile}
disabled={dirtyRiderIds.size > 0} disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery} onClick={handleFinalCreateDelivery}
> >
Assign Orders Assign Orders

View File

@@ -85,3 +85,274 @@ export const getActiveOrder = (orders) => {
}); });
return sorted.find(isActiveDelivery) || null; 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"> <MobileField label="KMS">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}> <Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top"> <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>
<Tooltip title="Cumulative Kms" placement="right"> <Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" /> <Chip size="small" label={`${val.cumulativekms} km`} color="success" />
@@ -559,7 +559,7 @@ const OrdersPreview = () => {
<TableCell align="center"> <TableCell align="center">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}> <Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top"> <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>
<Tooltip title="Cumulative Kms" placement="right"> <Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" /> <Chip size="small" label={`${val.cumulativekms} km`} color="success" />
@@ -668,7 +668,7 @@ const OrdersPreview = () => {
<TableCell align="center"> <TableCell align="center">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}> <Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top"> <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>
<Tooltip title="Cumulative Kms" placement="right"> <Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" /> <Chip size="small" label={`${val.cumulativekms} km`} color="success" />

View File

@@ -2,6 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { useState, useEffect, useMemo, useCallback, Fragment, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, Fragment, useRef } from 'react';
import logger from '../../../utils/logger';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
dayjs.extend(utc); dayjs.extend(utc);
@@ -53,7 +54,8 @@ import {
MdNotes, MdNotes,
MdMyLocation, MdMyLocation,
MdOutlineFlag, MdOutlineFlag,
MdPerson MdPerson,
MdFlashOn
} from 'react-icons/md'; } from 'react-icons/md';
import { DeleteOutlined } from '@ant-design/icons'; import { DeleteOutlined } from '@ant-design/icons';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
@@ -62,8 +64,9 @@ import CircularLoader from 'components/CircularLoader';
import AiImage from '../../../assets/images/aiImage.png'; import AiImage from '../../../assets/images/aiImage.png';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders } from '../../api/api'; import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack } from 'pages/api/doormileApi'; 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. // Design tokens — shared with the rest of the redesigned operator pages.
@@ -97,13 +100,17 @@ const BRAND = '#C01227';
const BRAND_LIGHT = '#D25463'; const BRAND_LIGHT = '#D25463';
// Semantic per-row status palette, keyed by GET /admin/bookings' real `status` // Semantic per-row status palette, keyed by GET /admin/bookings' real `status`
// enum (lowercased — StatusBadge lowercases before lookup). Only // enum (lowercased — StatusBadge lowercases before lookup). pending_pickup,
// pending_pickup and converted_to_consignment have been observed against the // miler_assigned, pickup_scheduled, converted_to_consignment and cancelled are
// live API so far; the rest are reasonable guesses at likely sibling states // now confirmed live (see the ORDERS_STATUS_TABS comment above — a real
// with a safe unknown-status fallback in StatusBadge below. // 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 = { const ROW_STATUS_META = {
pending_pickup: { label: 'Pending Pickup', color: '#f59e0b', icon: MdHourglassEmpty }, pending_pickup: { label: 'Pending Pickup', color: '#f59e0b', icon: MdHourglassEmpty },
pending_assignment: { label: 'Pending Assignment', 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 }, converted_to_consignment: { label: 'Converted', color: '#6366f1', icon: MdCheckCircle },
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle }, delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel } 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 // below. `status` values here are the raw, lowercased GET /admin/bookings
// enum (matches ROW_STATUS_META's keys), not api.js's separate generic // enum (matches ROW_STATUS_META's keys), not api.js's separate generic
// pending/accepted/delivered mapping used by the Deliveries page. // 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 = [ const ORDERS_STATUS_TABS = [
{ idx: 0, status: 'pending_pickup', label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty }, { idx: 0, status: 'pending_pickup', statuses: ['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: 1,
{ idx: 3, status: 'cancelled', label: 'Cancelled', color: '#ef4444', icon: MdCancel } 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` // Haversine straight-line distance in km — GET /admin/bookings has no `kms`
@@ -360,10 +398,30 @@ const Orders = () => {
return counts; return counts;
}, [allBookings]); }, [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 filteredRows = useMemo(() => {
const q = debouncedSearch.trim().toLowerCase(); const q = debouncedSearch.trim().toLowerCase();
return allBookings.filter((b) => { 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; if (!q) return true;
return ( return (
b.bookingno?.toLowerCase().includes(q) || b.bookingno?.toLowerCase().includes(q) ||
@@ -372,7 +430,7 @@ const Orders = () => {
b.notes?.toLowerCase().includes(q) b.notes?.toLowerCase().includes(q)
); );
}); });
}, [allBookings, currentStatus, debouncedSearch]); }, [allBookings, activeTabStatuses, debouncedSearch]);
// "Load more" now reveals more of the already-fetched, already-filtered // "Load more" now reveals more of the already-fetched, already-filtered
// set instead of requesting another server page (the bulk fetch above // set instead of requesting another server page (the bulk fetch above
@@ -473,9 +531,12 @@ const Orders = () => {
return { return {
...val, ...val,
deliveryid: 0, deliveryid: 0,
deliverydate: dayjs(val.serviceoptions?.[0]?.estimateddeliveryat || val.createdat) // Doormile timestamps are naive IST wall-clock (express-console-api.md) —
.utc() // .utc() was reinterpreting that as local time and shifting it back
.format('YYYY-MM-DD HH:mm:ss'), // 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'), assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
orderstatus: 'pending', orderstatus: 'pending',
orderamount: charge, 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. // KPI tile definitions.
const kpiCards = [ const kpiCards = [
{ {
@@ -561,6 +686,39 @@ const Orders = () => {
</Button> </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) || ============================================= */} {/* ============================================= || Header (compact) || ============================================= */}
<Paper <Paper
elevation={0} elevation={0}
@@ -658,7 +816,7 @@ const Orders = () => {
{ORDERS_STATUS_TABS.map((t) => { {ORDERS_STATUS_TABS.map((t) => {
const Icon = t.icon; const Icon = t.icon;
const active = tabvalue === t.idx; const active = tabvalue === t.idx;
const count = statusCounts[t.status] ?? 0; const count = t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0);
return ( return (
<Box <Box
key={t.status} key={t.status}
@@ -903,7 +1061,7 @@ const Orders = () => {
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.125 }}> <Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.125 }}>
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} /> <MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
{(() => { {(() => {
const dateObj = dayjs(row.createdat); const dateObj = parseDoormileTimestamp(row.createdat);
return ( return (
<> <>
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap> <Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
@@ -1012,28 +1170,6 @@ const Orders = () => {
<TableCell align="right"> <TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end"> <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 && ( {currentStatus === 'pending_pickup' && isCancellable && (
<Tooltip title="Cancel Order"> <Tooltip title="Cancel Order">
<IconButton <IconButton

View File

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