diff --git a/doormile-flow.md b/doormile-flow.md new file mode 100644 index 0000000..4c76e9d --- /dev/null +++ b/doormile-flow.md @@ -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. diff --git a/src/components/nearle_components/LocationAutocomplete.js b/src/components/nearle_components/LocationAutocomplete.js index 1c844b9..1ea3b8c 100644 --- a/src/components/nearle_components/LocationAutocomplete.js +++ b/src/components/nearle_components/LocationAutocomplete.js @@ -27,7 +27,12 @@ const LocationAutocomplete = forwardRef( }, ref ) => { - const [locations, setLocations] = useState(JSON.parse(localStorage.getItem('applocations') || '[]')); + // Tenant-scoped cache key — fetchAppLocations() now narrows the hub list + // to the logged-in tenant's own city, so caching under one shared flat + // key would leak a previous tenant's list into a different tenant's + // session on the same browser if it ever skipped a full logout clear. + const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`; + const [locations, setLocations] = useState(JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]')); useEffect(() => { // Zones are derived from GET /admin/hubs (the new API has no dedicated @@ -35,7 +40,7 @@ const LocationAutocomplete = forwardRef( const fetchLocations = async () => { try { const updatedLocations = await fetchAppLocations(); - localStorage.setItem('applocations', JSON.stringify(updatedLocations)); + localStorage.setItem(applocationsCacheKey, JSON.stringify(updatedLocations)); setLocations(updatedLocations); } catch (err) { console.error('Error fetching locations in LocationAutocomplete:', err); @@ -45,7 +50,7 @@ const LocationAutocomplete = forwardRef( if (locations.length === 0) { fetchLocations(); } - }, [locations.length]); + }, [locations.length, applocationsCacheKey]); // Helpers (only used by pill variant) — match the deliveries page's // token shorthand so the same opacity ramp is applied here. diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 300e4c0..40c8054 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -38,15 +38,33 @@ import { // lifecycle keys (pending/accepted/arrived/picked/active/skipped/delivered/ // cancelled) for its tabs, chip counts, and row badges. The new booking // status enum uses different strings entirely (Pending_Pickup, -// Converted_To_Consignment, Out_for_Delivery, ...) — only Pending_Pickup, -// Converted_To_Consignment (from a real sample) and Out_for_Delivery (named -// in express-console-api.md's CityGate note) are confirmed; the rest of this -// mapping is a best-effort guess. Unmapped statuses pass through lowercased, -// which the page's own fallback renders as an "unknown" badge rather than -// crashing. There's no confirmed equivalent for arrived/picked/skipped at -// all, so those tabs will show a 0 count until the real enum is confirmed. +// Miler_Assigned, Pickup_Scheduled, Converted_To_Consignment, Out_for_Delivery, +// ...) — Pending_Pickup, Converted_To_Consignment, Out_for_Delivery (named in +// express-console-api.md's CityGate note), and now Miler_Assigned / +// Pickup_Scheduled (confirmed live via a real booking-status breakdown logged +// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS +// comment) are confirmed; the rest of this mapping is still a best-effort +// guess. Unmapped statuses pass through lowercased, which the page's own +// fallback renders as an "unknown" badge rather than crashing. There's no +// confirmed equivalent for arrived/picked/skipped at all, so those tabs will +// show a 0 count until the real enum is confirmed. +// +// Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'. +// Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler +// from this console); "Accepted" is meant to reflect the RIDER's own action +// (doormile-flow.md's POST /miler/assignments/:id/accept). Conflating the two +// made a just-assigned, not-yet-acknowledged order look already-accepted — +// wrong from an ops standpoint (per explicit product requirement: stays +// "Pending" in the operator's eyes until the rider actually accepts). +// Pickup_Scheduled — the status that appears once a rider has accepted and +// the pickup is on their route — is the best available proxy for "rider +// accepted" in the currently-confirmed enum, so that one maps to 'accepted'. +// If the backend turns out to have a distinct status specifically for the +// accept action, add it here rather than reusing Miler_Assigned for it. const BOOKING_STATUS_TO_DELIVERY_STATUS = { pending_pickup: 'pending', + miler_assigned: 'pending', + pickup_scheduled: 'accepted', converted_to_consignment: 'accepted', out_for_delivery: 'active', delivered: 'delivered', @@ -101,6 +119,19 @@ export const getRiderPeriodicLogs = async (userid) => { // ==============================|| fetchAppLocations (zone/location picker) ||============================== // // The new API has no "zones" resource — applocationid only exists as a field // on Hubs. Derive a zone picker list from the distinct cities in GET /admin/hubs. +// +// Scoped per tenant: Hub has no tenantid field at all (confirmed against +// express-console-api.md), so GET /admin/hubs always returns every hub +// nationwide with no server-side tenant filtering possible. A client-tenant +// login should only see their own city's hub(s) — inferred from the +// tenant's own GET /admin/tenants/:id/locations (city is free text there, +// and on the hub side too, so matched via normalized exact-then-substring +// comparison, not a clean foreign key). Staff logins (tenantid falsy) keep +// seeing every hub, unchanged. Fails open (full list) whenever the tenant's +// city can't be determined or nothing matches, rather than ever locking an +// operator out with an empty picker. +const normCity = (s) => String(s || '').trim().toLowerCase(); + export const fetchAppLocations = async () => { try { const hubs = await getHubs(); @@ -110,7 +141,36 @@ export const fetchAppLocations = async () => { seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname }); } }); - return [...seen.values(), { locationname: 'All', applocationid: 0 }]; + const allLocations = [...seen.values()]; + + const tenantid = localStorage.getItem('tenantid'); + const isStaff = !tenantid || tenantid === '0'; + if (isStaff) { + return [...allLocations, { locationname: 'All', applocationid: 0 }]; + } + + const tenantLocations = await gettenantlocations(tenantid); + const tenantCities = new Set((tenantLocations || []).map((loc) => normCity(loc.city)).filter(Boolean)); + + if (tenantCities.size === 0) { + OpenToast("Could not determine your tenant's city — showing all zones.", 'warning', 3000); + return [...allLocations, { locationname: 'All', applocationid: 0 }]; + } + + let matched = allLocations.filter((loc) => tenantCities.has(normCity(loc.locationname))); + if (matched.length === 0) { + matched = allLocations.filter((loc) => { + const hubNorm = normCity(loc.locationname); + return [...tenantCities].some((cityNorm) => hubNorm.includes(cityNorm) || cityNorm.includes(hubNorm)); + }); + } + + if (matched.length === 0) { + OpenToast("No zones matched your tenant's city — showing all zones.", 'warning', 3000); + return [...allLocations, { locationname: 'All', applocationid: 0 }]; + } + + return matched; } catch (err) { OpenToast(err.message, 'error', 2000); return [{ locationname: 'All', applocationid: 0 }]; @@ -175,10 +235,18 @@ export const fetchPaymentType = async () => []; export const fetchRidersList = async () => { try { const milers = await getMilers(); - return (milers || []).map((val) => ({ - ...val, - label: `${val.displayname || val.authname || ''} | ${val.contactno || ''}` - })); + return (milers || []).map((val) => { + const name = val.displayname || val.authname || ''; + return { + ...val, + // Only append " | phone" when a phone actually exists — an + // unconditional template literal left a dangling " | " on every + // rider missing contactno, rendering literally in every dropdown + // that falls back to this default label (e.g. OrdersPreview.js, + // Preview.js's Change Rider dialog). + label: val.contactno ? `${name} | ${val.contactno}` : name + }; + }); } catch (err) { // Was `throw`ing after already toasting here — deliveries.js also wires // its own onError toast on this same query, so a real failure showed @@ -201,7 +269,16 @@ export const createOptimisationDeliveries = async (deliveryData) => { // ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== // export const reconcileSteps = async ({ riders }) => { + logger.debug(`reconcileSteps: posting ${riders?.length ?? 0} rider(s)`, riders); const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/reconcile-steps`, { riders }); + // Diagnostic: this is an external, unverified solver contract (see this + // area's CLAUDE.md) — Preview.js's reconcileMutation only clears the + // dirty-rider set (which is what re-enables Assign Orders) when + // response.data.riders is an array. If the solver's real response shape + // is different (wrapped in an envelope, a different key name, etc.), + // that check silently fails every time and Assign Orders can never + // re-enable — logging the exact raw shape here settles it either way. + logger.debug('reconcileSteps: raw response.data', response.data); return response.data; }; @@ -232,6 +309,49 @@ export const fetchBatchEfficiency = async ({ batch, tenantId }) => { // (that one creates a brand-new booking from scratch; wrong here, since // every order already exists as a Doormile booking pulled from // GET /admin/bookings). +// Shared by finalCreatedeliveries (here) and Preview.js's pre-commit +// verification UI, so both use the IDENTICAL matching rule rather than two +// copies that could silently drift apart. userid/milerprofileid matching is +// tried first (the solver's rider pool is fed full miler objects for +// Auto/multi-trip mode — see Preview.js's handleCreateDelivery -> `riders: +// autoRiders`, raw GET /admin/milers data carrying both fields per rider) +// but confirmed live that for Bike hypertuning mode NEITHER matches: that +// solver never receives a rider pool at all and assigns from its own +// internal roster, seeded against jupiter rider ids when the integration +// was first built — disconnected from Doormile's id space entirely (see +// jupiter2doormile.md comparison). Falls back to matching the rider's NAME +// (also echoed by the solver, see flattenRiders' rider_name) against each +// miler's displayname/authname — the only other correlatable field. +const normMilerName = (s) => String(s || '').trim().toLowerCase(); + +export const buildMilerLookup = (milers) => { + const byUserId = new Map((milers || []).map((m) => [String(m.userid), m])); + const byProfileId = new Map((milers || []).map((m) => [String(m.milerprofileid), m])); + const byName = new Map(); + (milers || []).forEach((m) => { + [m.displayname, m.authname].forEach((n) => { + const key = normMilerName(n); + if (key && !byName.has(key)) byName.set(key, m); + }); + }); + return { byUserId, byProfileId, byName }; +}; + +export const resolveMilerForOrder = (order, lookup) => { + const riderUserId = order.rider_id ?? order.userid; + const riderName = order.rider_name ?? order.rider; + const matchedVia = lookup.byUserId.has(String(riderUserId)) + ? 'userid' + : lookup.byProfileId.has(String(riderUserId)) + ? 'milerprofileid' + : lookup.byName.has(normMilerName(riderName)) + ? 'name' + : null; + const rider = + lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName)); + return rider?.milerprofileid ? { rider, matchedVia } : null; +}; + export const finalCreatedeliveries = async (deliveryData) => { const deliveries = deliveryData.deliveries || []; logger.debug(`finalCreatedeliveries: ${deliveries.length} order(s) to assign`); @@ -253,19 +373,6 @@ export const finalCreatedeliveries = async (deliveryData) => { }); }); - // assign-miler needs a milerprofileid. userid/milerprofileid matching was - // tried first (the solver's rider pool is fed full miler objects — see - // Preview.js's handleCreateDelivery -> `riders: autoRiders`, raw - // GET /admin/milers data carrying both fields per rider) but confirmed - // live that NEITHER matches: the solver's rider_id/userid (e.g. "883") - // isn't in GET /admin/milers under either field. This matches - // Dispatch.js's own Analysis-panel comment — "the workolik solver doesn't - // have our auth/users table" — so its numeric rider ids are its own - // internal numbering, disconnected from Doormile's id space entirely. - // Falls back to matching the rider's NAME (also echoed by the solver, - // see flattenRiders' rider_name) against each miler's displayname/ - // authname — the only other correlatable field. Logs the full roster - // alongside so a further mismatch is fully diagnosable from one run. let milers = []; try { milers = (await getMilers()) || []; @@ -276,47 +383,62 @@ export const finalCreatedeliveries = async (deliveryData) => { `finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`, milers.map((m) => ({ userid: m.userid, milerprofileid: m.milerprofileid, name: m.displayname || m.authname })) ); - const milerByUserId = new Map(milers.map((m) => [String(m.userid), m])); - const milerByProfileId = new Map(milers.map((m) => [String(m.milerprofileid), m])); - const normName = (s) => String(s || '').trim().toLowerCase(); - const milerByName = new Map(); - milers.forEach((m) => { - [m.displayname, m.authname].forEach((n) => { - const key = normName(n); - if (key && !milerByName.has(key)) milerByName.set(key, m); - }); - }); + const lookup = buildMilerLookup(milers); + + // Booking id resolution: confirmed live that guessing a single field name + // (bookingid first, on the assumption the solver passes unknown fields + // through untouched) sends assign-miler a wrong id — a small, sequential- + // looking number that 404s. The AI solver is a separate, unverified + // service (see this area's CLAUDE.md); there's no reliable way to know + // which candidate field it actually preserves. Instead of guessing, + // fetch the tenant's real current booking list once and VALIDATE each + // candidate against it, using whichever one actually matches a real + // booking — this is correct regardless of which field the solver happens + // to preserve, and regardless of which one changes in a future solver + // update. + let realBookingIds = new Set(); + try { + const realBookings = (await getBookings(1, 1000)) || []; + realBookingIds = new Set(realBookings.map((b) => String(b.bookingid))); + logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`); + } catch (err) { + logger.error('finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message); + } const results = await Promise.allSettled( deliveries.map(async (d) => { - // Booking id: the solver is a separate, unverified service (see this - // area's CLAUDE.md) — falls back through every plausible field name - // an order might carry it under, `bookingid` first since that's what - // orders.js actually sends in and most solvers pass unknown fields - // through untouched. - const bookingId = d.bookingid ?? d.orderheaderid ?? d.deliveryid ?? d.orderid; + const candidates = [d.bookingid, d.orderheaderid, d.deliveryid, d.orderid]; + const bookingId = candidates.find((c) => c != null && realBookingIds.has(String(c))); const riderUserId = d.rider_id ?? d.userid; const riderName = d.rider_name ?? d.rider; - const matchedVia = milerByUserId.has(String(riderUserId)) - ? 'userid' - : milerByProfileId.has(String(riderUserId)) - ? 'milerprofileid' - : milerByName.has(normName(riderName)) - ? 'name' - : null; - const rider = - milerByUserId.get(String(riderUserId)) ?? milerByProfileId.get(String(riderUserId)) ?? milerByName.get(normName(riderName)); - if (bookingId == null || !rider?.milerprofileid) { + const resolved = resolveMilerForOrder(d, lookup); + if (bookingId == null || !resolved) { const reason = bookingId == null - ? 'no booking id resolved on the order object' + ? `no candidate id matched a real booking (tried bookingid=${d.bookingid}, orderheaderid=${d.orderheaderid}, deliveryid=${d.deliveryid}, orderid=${d.orderid})` : `no miler found for rider id ${riderUserId} / name "${riderName}" (checked userid, milerprofileid, name)`; logger.error(`finalCreatedeliveries: skipping order — ${reason}`); throw new Error(`order ${d.orderid ?? bookingId ?? '?'}: ${reason}`); } + const { rider, matchedVia } = resolved; logger.debug(`finalCreatedeliveries: order booking ${bookingId} -> rider ${riderUserId} ("${riderName}") matched via ${matchedVia}`); try { - return await assignMilerToBooking(bookingId, { milerid: rider.milerprofileid }); + // doormile-flow.md (confirmed current, authoritative): assign-miler's + // body key is `mileruserid`, and it's the miler's userid — a + // DIFFERENT identity space than milerprofileid, which is what admin + // miler endpoints (notify, block, etc.) key on instead. Sending + // { milerid: rider.milerprofileid } was wrong on both the key name + // and the value's identity space — the actual root cause of the + // persistent 404s on this call. + await assignMilerToBooking(bookingId, { mileruserid: rider.userid }); + // Return the REAL resolved milerprofileid, not the solver's own + // rider_id/userid — the caller (Preview.js) needs this for + // notifyRider, which takes a milerprofileid specifically. Before + // this, Preview.js notified using the raw solver id directly, which + // is neither a real userid nor a milerprofileid (see the matching + // comment above) — so rider push notifications were going out with + // a bogus id and very likely silently failing server-side. + return { milerprofileid: rider.milerprofileid }; } catch (err) { logger.error( `finalCreatedeliveries: assign-miler failed for booking ${bookingId}`, @@ -337,7 +459,12 @@ export const finalCreatedeliveries = async (deliveryData) => { if (failed.length) { OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000); } - return { success: true, assigned: deliveries.length - failed.length, failed: failed.length }; + const resolvedMilerProfileIds = [ + ...new Set( + results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid) + ) + ]; + return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds }; }; // ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== // // Also part of the optimiser pipeline (routes.workolik.com / routemate.workolik.com) — untouched. @@ -486,7 +613,16 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { return { rows, - nextPage: rows.length === Number(rowsPerPage) ? pageParam + 1 : undefined + // Whether to fetch another page must be based on the RAW bookings page + // (bookings.length), not the post-filter `rows.length` — most bookings on + // any given page are still pending, not dispatched, so the filtered count + // almost never equals rowsPerPage. Comparing the filtered count against + // rowsPerPage (the previous logic) made pagination stop after page 1 in + // virtually every real dataset, silently hiding dispatched/assigned + // orders that live beyond the first `rowsPerPage` bookings — e.g. a + // booking just created and assigned wouldn't show on the Deliveries page + // at all once the tenant has more than one page's worth of bookings. + nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined }; }; @@ -541,19 +677,27 @@ export const cancelDeliveryAPI = async (selectedRow, cancelFeed) => export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid); // ==============================|| changeRiderAPI (deliveries) ||============================== // -// POST /admin/bookings/:id/assign-miler has no documented body — `milerid` as -// the JSON key is a guess (unverified, this is a write endpoint we didn't -// test live), but the VALUE must be the miler's milerprofileid regardless of -// key name: selectedRider comes straight from getMilers(), whose own .userid -// field is a different resource (confirmed live via GET /admin/milers/:id). +// doormile-flow.md (confirmed current, authoritative) settles this: the body +// is { "mileruserid": } — the previous guess here (`milerid` +// key, `milerprofileid` value) was wrong on both counts. Admin miler +// endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the +// one exception that wants userid instead — "different identity spaces on +// adjacent endpoints," per that doc's own wording. selectedRider comes +// straight from getMilers(), which carries both fields on the same object. export const changeRiderAPI = async (selectedRider, selectedRow) => - assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { milerid: selectedRider.milerprofileid }); + assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid }); // ==============================|| updateDeliveryAPI (deliveries) ||============================== // // No amount/notes field exists on PUT /admin/consignments/:id/status — closest // available write is a status update. Free-text amount/notes edits have no home // in the new API yet. -export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.deliveryid ?? orderData.consignmentid, orderData); +// Target endpoint is consignment-scoped (/admin/consignments/:id/status), so +// this needs the real consignmentid, not a booking id. deliveryid on a +// deliveries-page row is always b.bookingid (see fetchDeliveries) — always +// truthy, so `deliveryid ?? consignmentid` never actually fell through to +// consignmentid even when it was present, silently calling the endpoint +// with the wrong kind of id on every Update Status submit. +export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.consignmentid ?? orderData.deliveryid, orderData); // ==============================|| getalltenants (tenants) ||============================== // diff --git a/src/pages/api/doormileApi.js b/src/pages/api/doormileApi.js index 430a894..78c5546 100644 --- a/src/pages/api/doormileApi.js +++ b/src/pages/api/doormileApi.js @@ -346,6 +346,20 @@ export const assignMilerToBooking = async (id, data) => { return response.data; }; +// Doormile-native bulk assign: picks real milers via Redis GEO + AI scoring, no +// external solver involved. Commits server-side in this one call — unlike the +// workolik solver flow, there's no separate preview/reconcile/commit step. +// Sequencing (POST /optimization/doormile/sequence) is not deployed yet, so +// riders with more than one stop come back unsequenced (step: 0) — see +// doormile-flow.md's State table. +export const batchAssignBookings = async (bookingIds, maxPerRider = 5) => { + const response = await doormileAxios.post('/hub/bookings/batch-assign', { + bookingids: bookingIds, + max_per_rider: maxPerRider + }); + return response.data; +}; + export const assignVehicleToBooking = async (id, data) => { const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data); return response.data; diff --git a/src/pages/nearle/clientPricing/clientPricing.js b/src/pages/nearle/clientPricing/clientPricing.js index 31edb8f..d08fc69 100644 --- a/src/pages/nearle/clientPricing/clientPricing.js +++ b/src/pages/nearle/clientPricing/clientPricing.js @@ -85,7 +85,7 @@ const soft = (c) => a(c, '18'); const ring = (c) => a(c, '26'); const edge = (c) => a(c, '55'); -const BRAND = '#662582'; +const BRAND = '#C01227'; const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van']; const SoftPaper = (props) => ( @@ -199,20 +199,30 @@ const ClientsPricing = () => { const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]); const locationMap = useMemo( - () => new Map((locations || []).filter((l) => l.applocationid).map((l) => [l.applocationid, l])), + () => new Map((locations || []).filter((l) => l.applocationid).map((l) => [String(l.applocationid), l])), [locations] ); const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]); const tenantName = (row) => row.tenantname || tenantMap.get(row.tenantid)?.tenantname || (row.tenantid ? `Tenant #${row.tenantid}` : '—'); const zoneName = (row) => - row.applocation || locationMap.get(row.applocationid)?.locationname || (row.applocationid ? `Zone #${row.applocationid}` : '—'); + row.applocation || + locationMap.get(String(row.applocationid))?.locationname || + (row.applocationid ? `Zone #${row.applocationid}` : '—'); // getallpricing() takes no zone arg (see api.js) — the LocationAutocomplete // only ever changed the header subtitle text, not which rows were shown. // Filter client-side instead; pricing rows already carry applocationid // (used by zoneName above), so this is the same real field, just applied. - const zonePricing = useMemo(() => (appId ? pricing.filter((r) => r.applocationid === appId) : pricing), [pricing, appId]); + // Compared as strings — applocationid on a pricing row (GET /admin/pricing) + // and on a hub/location (GET /admin/hubs, what the picker's appId comes + // from) are two independently-serialized resources with no guarantee they + // agree on number-vs-string; a raw === silently zeroed out every row + // whenever they didn't match, making the table look empty for any zone. + const zonePricing = useMemo( + () => (appId ? pricing.filter((r) => String(r.applocationid) === String(appId)) : pricing), + [pricing, appId] + ); const rows = useMemo(() => { if (!debouncedSearch) return zonePricing; diff --git a/src/pages/nearle/clients/createCustomer.js b/src/pages/nearle/clients/createCustomer.js index 0f5b609..a48bbc0 100644 --- a/src/pages/nearle/clients/createCustomer.js +++ b/src/pages/nearle/clients/createCustomer.js @@ -75,13 +75,15 @@ const Createcustomer = () => { }); // LocationAutocomplete only reports back applocationid/locationname — it - // caches the full hub list (with lat/lng) in localStorage('applocations') + // caches the full hub list (with lat/lng) in localStorage under a + // tenant-scoped key (applocations_, see LocationAutocomplete.js) // as a side effect, so pull the coordinates for the address-search bias // from there rather than duplicating the GET /admin/hubs call. useEffect(() => { if (!appId) return; try { - const hubs = JSON.parse(localStorage.getItem('applocations') || '[]'); + const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`; + const hubs = JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]'); const hub = hubs.find((h) => h.applocationid === appId); if (hub?.latitude) { setAppLocaLat(hub.latitude); diff --git a/src/pages/nearle/customers/customers.js b/src/pages/nearle/customers/customers.js index 2244f8f..41a01bf 100644 --- a/src/pages/nearle/customers/customers.js +++ b/src/pages/nearle/customers/customers.js @@ -9,7 +9,6 @@ import { Button, Grid, IconButton, - InputLabel, Paper, Stack, Table, @@ -24,6 +23,7 @@ import { useMediaQuery, useTheme } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; import { MdPeopleAlt, MdOutlinePeopleAlt, @@ -43,6 +43,7 @@ import Loader from 'components/Loader'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import PageHeader from 'components/nearle_components/PageHeader'; import StatCard from 'components/nearle_components/StatCard'; +import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi'; @@ -54,6 +55,13 @@ import { enqueueSnackbar } from 'notistack'; // all. PATCH /admin/customers/:id is the only mutation the API exposes; // there is no create/delete here by design ("Tenant-scoped through their // bookings" — a customer only exists once they've ordered through a tenant). +// +// The edit dialog replicates nearle_console_express's customer edit dialog +// field-for-field (Name, Contact, Address, Location, City, State, Postcode, +// Landmark, Latitude, Longitude) — same layout, same AddressAutocomplete +// wiring — but only `name/phone/email` are ever sent in the PATCH body; the +// address fields have nowhere to persist to on this API (same "collected but +// not persisted" pattern as `pages/nearle/clients/createCustomer.js`). // ============================================================================ const DT = { @@ -97,6 +105,9 @@ const Customers = () => { const [debouncedSearch, setDebouncedSearch] = useState(''); const [editRow, setEditRow] = useState(null); const [form, setForm] = useState({}); + const [addressInput, setAddressInput] = useState(''); + const [pickAddress, setPickAddress] = useState({}); + const [latLng, setLatLng] = useState({ latitude: '', longitude: '' }); const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers }); @@ -139,6 +150,44 @@ const Customers = () => { phone: row.phone || '', email: row.email || '' }); + setAddressInput(row.address || ''); + setPickAddress({ + doorno: row.doorno || '', + suburb: row.suburb || '', + city: row.city || '', + state: row.state || '', + postcode: row.postcode || '', + landmark: row.landmark || '' + }); + setLatLng({ latitude: row.latitude || '', longitude: row.longitude || '' }); + }; + + // Same address_components parsing as createCustomer.js's handlePlaceSelected — + // AddressAutocomplete shapes Nominatim results to look like a Google Places `place`. + const handleAddressPlaceSelected = (place) => { + setLatLng({ latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng() }); + const parsed = { suburb: '', city: '', state: '', postcode: '' }; + place.address_components.forEach((component) => { + component.types.forEach((type) => { + switch (type) { + case 'sublocality_level_1': + case 'sublocality': + parsed.suburb = component.long_name; + break; + case 'locality': + parsed.city = component.long_name; + break; + case 'administrative_area_level_1': + parsed.state = component.long_name; + break; + case 'postal_code': + parsed.postcode = component.long_name; + break; + } + }); + }); + setPickAddress((p) => ({ ...p, ...parsed })); + setAddressInput(place.formatted_address); }; return ( @@ -188,13 +237,21 @@ const Customers = () => { background: '#fff' }} > - + - + Directory @@ -388,7 +445,14 @@ const Customers = () => { )} - setEditRow(null)} maxWidth="xs" fullWidth PaperProps={{ sx: { borderRadius: 3 } }}> + setEditRow(null)} + maxWidth="lg" + fullWidth + fullScreen={isMobile} + PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }} + > { - + Customer @@ -411,16 +477,22 @@ const Customers = () => { - - - Name - setForm((f) => ({ ...f, name: e.target.value }))} /> - - - Phone Number + + + Customer Name + setForm((f) => ({ ...f, name: e.target.value }))} + /> + + + Contact Number - + { InputProps={{ startAdornment: }} /> - - - Email + + + Email setForm((f) => ({ ...f, email: e.target.value }))} InputProps={{ startAdornment: }} /> - - + + + + + The backend doesn't store an address against a customer yet — these fields aren't required and won't be saved + on submit. + + + + + Address + setAddressInput(text)} + onPlaceSelected={handleAddressPlaceSelected} + TextFieldProps={{ + variant: 'outlined', + InputProps: { + endAdornment: ( + { + setAddressInput(''); + setPickAddress((p) => ({ ...p, suburb: '', city: '', state: '', postcode: '' })); + setLatLng({ latitude: '', longitude: '' }); + }} + size="small" + > + + + ) + } + }} + /> + + + Location + setPickAddress((p) => ({ ...p, suburb: e.target.value }))} + /> + + + City + setPickAddress((p) => ({ ...p, city: e.target.value }))} + /> + + + State + setPickAddress((p) => ({ ...p, state: e.target.value }))} + /> + + + Postcode + setPickAddress((p) => ({ ...p, postcode: e.target.value }))} + /> + + + Landmark + setPickAddress((p) => ({ ...p, landmark: e.target.value }))} + /> + + + Latitude + + + + Longitude + + + - 0 ? `Reconcile ${dirtyRiderIds.size} edited rider(s) first` : ''}> + 0 + ? `Reconcile ${dirtyRiderIds.size} edited rider(s) first` + : unverifiedOrderIds.size > 0 + ? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first` + : '' + } + > )} + {selectedOrders.length > 0 && currentStatus === 'pending_pickup' && ( + + + + )} + {/* ============================================= || Header (compact) || ============================================= */} { {ORDERS_STATUS_TABS.map((t) => { const Icon = t.icon; const active = tabvalue === t.idx; - const count = statusCounts[t.status] ?? 0; + const count = t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0); return ( { {(() => { - const dateObj = dayjs(row.createdat); + const dateObj = parseDoormileTimestamp(row.createdat); return ( <> @@ -1012,28 +1170,6 @@ const Orders = () => { - - { - 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' - } - }} - > - - - {currentStatus === 'pending_pickup' && isCancellable && ( (option ? `${option.locationname} (${option.suburb})` : '')} + getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')} value={locationValue} PaperComponent={SoftPaper} onOpen={(event) => { @@ -725,7 +725,10 @@ export default function OrdersDetails() { `${option.firstname} ${option.lastname}`} + getOptionLabel={(option) => { + const name = option?.displayname || option?.authname || ''; + return option?.contactno ? `${name} (${option.contactno})` : name; + }} PaperComponent={SoftPaper} onOpen={() => { if (!appId) { diff --git a/src/utils/doormileTimestamp.js b/src/utils/doormileTimestamp.js new file mode 100644 index 0000000..8bab7a6 --- /dev/null +++ b/src/utils/doormileTimestamp.js @@ -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); +};