Files
Doormilexpress_console/src/pages/api/api.js

1068 lines
53 KiB
JavaScript

import axios from 'axios';
import { OpenToast } from 'components/third-party/OpenToast';
import logger from 'utils/logger';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
getMilers,
getMiler,
getMilerSummary,
getMilerLogs,
notifyMiler,
getAdminTenants,
getAdminCustomers,
getTenantLocations,
getHubs,
getBookings,
getBooking,
getConsignments,
assignMilerToBooking,
cancelBooking,
updateConsignmentStatus,
getAdminPricing,
getReports,
getLocationsSummary,
getProfile
} from 'pages/api/doormileApi';
// This file talks exclusively to api.doormile.com (see utils/doormileAxios.js /
// pages/api/doormileApi.js) — the old jupiter.nearle.app backend (REACT_APP_URL /
// REACT_APP_URL2) has been fully retired from this project. See
// express-console-api.md at the repo root for the endpoint reference.
//
// Several resources the old backend exposed (payment-mode types, tenant tab
// counts, rider check-in history, per-location/per-rider report breakdowns,
// rider login/checkout audit logs, live rider GPS/battery logs) have no
// equivalent in the new API. Those functions below return safe empty defaults
// (rather than throwing) so pages render an empty state instead of crashing —
// each is commented with why.
// The Deliveries page's STATUS_META/STATUS_TABS still use the old jupiter
// 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,
// 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, AND the
// Deliveries page now surfaces those rows under an "Other" tab that names the
// unmapped enum — previously such a row was counted nowhere and shown nowhere.
// `picked` now has a confirmed source (Converted_To_Consignment, below).
// `arrived` and `skipped` still have none: the rider actions that produce them
// (POST /miler/bookings/:id/reached, POST /miler/consignments/:id/skip) have no
// booking-status equivalent in the confirmed enum, so those two tabs stay at 0
// until the backend is confirmed to expose them.
//
// 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.
// Converted_To_Consignment is PICKED, not accepted. doormile-flow.md §5 is
// explicit: `POST /miler/bookings/:bookingid/pickup-complete` "converts the
// booking into a consignment" — so the status is written at the moment the
// rider completes pickup, and the parcel is in their hands. deliveries.js says
// the same thing from the other side ("this order hasn't been picked up yet —
// status can only be updated once it becomes a consignment").
//
// It sat on 'accepted', which held a picked-up parcel in the Accepted tab and
// left Picked permanently empty — nothing else in the enum maps to it.
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
pending_pickup: 'pending',
miler_assigned: 'pending',
pickup_scheduled: 'accepted',
converted_to_consignment: 'picked',
out_for_delivery: 'active',
delivered: 'delivered',
cancelled: 'cancelled'
};
// Exported (not just module-local) so anything else that needs to classify a
// raw booking status — the operator bot's status-breakdown intent included —
// reuses this instead of growing a second copy of BOOKING_STATUS_TO_DELIVERY_STATUS
// that can drift from it.
export const mapBookingStatusToDeliveryStatus = (status) => {
const key = String(status || '').toLowerCase();
return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key;
};
// A consignment's own status, when this booking has one and it looks like a
// status. `GET /admin/consignments` has no documented response schema, so this
// reads defensively: only a non-empty string on one of the plausible field
// names counts, and anything else returns undefined so the caller keeps using
// the booking's status.
const consignmentStatusFor = (booking, consignmentMap) => {
if (!booking?.consignmentid || !consignmentMap?.size) return undefined;
const record = consignmentMap.get(String(booking.consignmentid));
if (!record) return undefined;
const raw = record.status ?? record.consignmentstatus ?? record.currentstatus;
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
};
// Haversine straight-line distance in km — used wherever a resource carries
// lat/lng but no road-distance field (bookings, and by extension deliveries).
const haversineKm = (lat1, lon1, lat2, lon2) => {
if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) return undefined;
const R = 6371;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
// ==============================|| getRiderPeriodicLogs ||============================== //
// Confirmed by jupiter2doormile.md as the direct replacement for jupiter's
// riderlogs table (Status: Done): GET /admin/milers/:id/logs. Field names on
// a log entry aren't documented anywhere (no example JSON in either
// reference doc) — this returns the most recent entry as-is and lets the
// page's own optional-chaining render whatever fields actually come back.
// Falls back to the miler record itself (which may carry a last-known
// position) if the logs call fails or returns nothing.
// Despite the parameter name (kept for caller compatibility), this must be
// a milerprofileid — /admin/milers/:id/* routes 404 on a plain userid
// (confirmed live).
export const getRiderPeriodicLogs = async (userid) => {
if (!userid) return null;
try {
const logs = await getMilerLogs(userid, undefined, undefined, 1);
const latest = Array.isArray(logs) ? logs[0] : logs;
if (latest) return latest;
} catch {
// Falls through to the getMiler() fallback below — this is polled every
// 15s by the live map, so a toast here would spam the operator on every
// failed tick. Only surface an error if the fallback also fails.
}
try {
return (await getMiler(userid)) || null;
} catch (err) {
return null;
}
};
// ==============================|| 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();
const seen = new Map();
(hubs || []).forEach((hub) => {
if (hub.applocationid != null && !seen.has(hub.applocationid)) {
seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname });
}
});
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 }];
}
};
// ==============================|| fetchPercentageData (orders) ||============================== //
// Best-effort against /admin/reports — field names are our assumption since
// the doc only promises "counts + today's numbers", not an exact schema.
export const fetchPercentageData = async ({ queryKey }) => {
const [, , startdate, enddate] = queryKey;
try {
const details = (await getReports(startdate, enddate)) || {};
const total = details.total || 0;
return {
created: String(details.created || 0),
uncoveredOrders: String(details.pending || 0),
coveredOrders: String(details.delivered || 0),
cancelled: String(details.cancelled || 0),
percentage1: String(Math.round(((details.created || 0) / total) * 100) || 0),
percentage2: String(Math.round(((details.pending || 0) / total) * 100) || 0),
percentage3: String(Math.round(((details.delivered || 0) / total) * 100) || 0),
percentage4: String(Math.round(((details.cancelled || 0) / total) * 100) || 0)
};
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load order percentages', 'error', 2000);
return {
created: '0',
uncoveredOrders: '0',
coveredOrders: '0',
cancelled: '0',
percentage1: '0',
percentage2: '0',
percentage3: '0',
percentage4: '0'
};
}
};
// ===================================================== || getTenants || =====================================================
export const getTenants = async () => {
try {
const tenants = await getAdminTenants();
return (tenants || []).map((val) => ({ ...val, label: val.tenantname }));
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load tenants', 'error', 2000);
return [];
}
};
// ============================================= || gettenantlocations (branches) || =============================================
export const gettenantlocations = async (tenantId) => {
try {
return (await getTenantLocations(tenantId)) || [];
} catch (err) {
// Log only the message — the raw error object carries the request
// config, including the Authorization: Bearer <doormileToken> header
// doormileAxios attaches, which shouldn't hit the console.
OpenToast(err.response?.data?.message || err.message || 'Failed to load tenant locations', 'error', 2000);
return [];
}
};
// ==============================|| fetchPaymentType (orders) ||============================== //
// No payment-mode-types endpoint in the new API.
export const fetchPaymentType = async () => [];
// ==============================|| fetchRidersList (orders) ||============================== //
export const fetchRidersList = async () => {
try {
const milers = await getMilers();
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
// the operator the same error twice. Matches every sibling function's
// convention now: toast once, return a safe default, never throw (the
// other 4 call sites had no onError of their own and got zero feedback
// on failure before this fix).
OpenToast(err.response?.data?.message || err.message || 'Failed to load riders', 'error', 2000);
return [];
}
};
// ==============================|| createOptimisationDeliveries (orders) Arrange the order ||============================== //
// AI dispatch optimiser — a separate solver service, left untouched by this
// migration (no equivalent exists in the new admin API).
export const createOptimisationDeliveries = async (deliveryData) => {
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/createdeliveries`, deliveryData.deliveries);
return response.data;
};
// ==============================|| 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;
};
// ==============================|| fetchBatchEfficiency (Dispatch - Analysis view) ||============================== //
export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
const response = await axios.post(
`https://routes.workolik.com/api/v1/batch/efficiency`,
{ batch, tenant_id: tenantId },
{ headers: { 'Content-Type': 'application/json' }, validateStatus: () => true }
);
return response.data;
};
// ==============================|| finalCreatedeliveries (orders) ||============================== //
// Final delivery commit. Used to POST the whole batch to jupiter's
// /deliveries/createdeliveries in one shot — retired for two confirmed
// reasons: (1) it 500'd because jupiter's Deliveries Go struct types
// pickuplat/pickuplon/droplat/droplon as string, while GET /admin/bookings
// (the new Doormile API) feeds orders.js's solver payload with those as
// numbers; (2) even once that's patched, jupiter writes into jupiter's own
// now-orphaned database — neither the Orders "pending" list nor the
// Deliveries "dispatched" filter read it, since both come from Doormile's
// GET /admin/bookings. jupiter2doormile.md §4 (the "assign a rider" row,
// mapped from jupiter's old PUT /deliveries/updatedelivery) and
// express-console-api.md's exercised-routes list both confirm
// POST /admin/bookings/:id/assign-miler as the correct, live-proven
// replacement for this exact action — not POST /admin/expressbooking
// (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`);
if (!deliveries.length) return { success: true, assigned: 0, failed: 0 };
// Diagnostic only (no PII beyond a rider's display name, which every
// operator already sees in the UI). Shows exactly which candidate field
// names are actually present, so a wrong guess in the fallback chain
// below is visible instead of silently mismatching.
deliveries.forEach((d, i) => {
logger.debug(` order[${i}] id fields:`, {
bookingid: d.bookingid,
orderheaderid: d.orderheaderid,
deliveryid: d.deliveryid,
orderid: d.orderid,
rider_id: d.rider_id,
userid: d.userid,
rider_name: d.rider_name ?? d.rider
});
});
let milers = [];
try {
milers = (await getMilers()) || [];
} catch (err) {
logger.error('finalCreatedeliveries: GET /admin/milers failed', err.response?.status, err.response?.data || err.message);
}
logger.debug(
`finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`,
milers.map((m) => ({ userid: m.userid, milerprofileid: m.milerprofileid, name: m.displayname || m.authname }))
);
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) => {
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 resolved = resolveMilerForOrder(d, lookup);
if (bookingId == null || !resolved) {
const reason =
bookingId == null
? `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 {
// 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}`,
err.response?.status,
err.response?.data || err.message
);
throw err;
}
})
);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length === deliveries.length) {
// Total failure — throw so the caller's mutation onError fires instead
// of a false "success" toast + navigate-away with nothing assigned.
throw new Error(`Couldn't assign any of the ${deliveries.length} order(s)`);
}
if (failed.length) {
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
}
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.
export const createAutomationDeliveries = async (variables) => {
const absentRiders = Array.isArray(variables.absent_riders) ? variables.absent_riders : [];
const url =
variables.selectedMode.value == 1
? `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=${variables.hypertuning_params}`
: `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`;
const body =
variables.selectedMode.value == 1
? { deliveries: variables.deliveries, absent_riders: absentRiders }
: { ...(variables.data || {}), absent_riders: absentRiders };
const response = await axios.post(url, body);
return response.data;
};
// ==============================|| notifyRider (orders / deliveries) ||============================== //
// New API notifies by milerprofileid + title/message (server looks up the
// device token itself) instead of the caller passing an FCM token directly.
export const notifyRider = async (
milerProfileId,
title = 'DoormileXpress',
message = 'Orders have been placed for delivery. Kindly accept and process deliveries.'
) => {
if (!milerProfileId) {
throw new Error('Invalid miler profile id');
}
return notifyMiler(milerProfileId, title, message);
};
// ==============================|| fetchDeliveries (deliveries) ||============================== //
// GET /admin/consignments has zero documented response schema (see
// express-console-api.md — it's listed with no example body at all, unlike
// bookings). Rather than bind the deliveries table to an unknown shape, this
// builds the list from /admin/bookings (fully confirmed schema — see
// getBookings/fetchOrders in this same file) filtered to bookings that have
// actually been dispatched (assignedmileruserid or consignmentid set) — the
// Deliveries page is conceptually "orders that have moved past just being
// created", same underlying resource as the Orders page.
//
// Real consignment-only data (route step order, transit minutes, cumulative
// km, live delivery-leg status distinct from the booking's own status) has no
// confirmed field names and is NOT fabricated here — those cells render a
// "—" placeholder in the UI instead of invented numbers. If GET
// /admin/consignments' real shape is confirmed later, this is the function to
// extend with a join by booking.consignmentid.
//
// Each row is normalised onto the *old* jupiter field names the Deliveries
// page already reads (orderheaderid, deliverycustomer, ridername, etc.) so
// deliveries.js itself doesn't need a full rewrite — same compatibility-shim
// approach as fetchAllRiders' `{details}` wrapper.
export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// Every caller builds the key as
// [name, appId, userid, status, startdate, enddate, rowsPerPage, …]
// (Dispatch.js, deliveries.js, reports/ordersDetails.js,
// reports/profitability.js — all four verified). Only rowsPerPage used to be
// read here, which meant startdate/enddate were accepted and then ignored:
// changing the date on the Dispatch page rebuilt the query key and refetched,
// but issued an identical request, so the row set — and every batch count
// derived from it — was byte-identical for today, yesterday and last week.
//
// GET /admin/bookings takes only pageno/pagesize (doormileApi.js; no date
// parameter is documented in express-console-api.md and guessing one risks a
// silent 400 or, worse, a silently-ignored filter), so the range is applied
// client-side below, after the rows are normalised.
const [, , , , startdate, enddate, rowsPerPage] = queryKey;
// Unlike the 3 joins below (customers/milers/tenants — each individually
// guarded so a failed join just degrades a display field, not the whole
// page), a failed bookings call is the one thing this function can't
// recover from — the previous version let it reject the whole Promise.all
// uncaught, with no toast anywhere across the 4 pages that call this.
let bookings;
try {
bookings = await getBookings(pageParam, rowsPerPage);
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load deliveries', 'error', 2000);
return { rows: [], nextPage: undefined };
}
// Consignments are joined for ONE reason: once a booking becomes a
// consignment, its lifecycle continues on the CONSIGNMENT record and the
// booking's own `status` stops moving. `PUT /admin/consignments/:id/status`
// — the Update Status dialog — writes there, so the write succeeded, the
// toast said so, and this page went on showing the booking's stale
// `Converted_To_Consignment` because that is the only field it read.
//
// Guarded like the other three joins: if the call fails, or the response
// carries no recognisable status, the row falls back to the booking's status
// and behaviour is exactly what it was before this join existed.
const [customers, milers, tenants, consignments] = await Promise.all([
getAdminCustomers().catch(() => []),
getMilers().catch(() => []),
getAdminTenants().catch(() => []),
getConsignments().catch(() => [])
]);
// The id field on a consignment record has never been captured, so both
// plausible names are indexed rather than guessing one.
const consignmentMap = new Map();
(consignments || []).forEach((c) => {
const id = c?.consignmentid ?? c?.id;
if (id != null) consignmentMap.set(String(id), c);
});
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
const milerMap = new Map((milers || []).map((m) => [m.userid ?? m.milerid, m]));
const tenantMap = new Map((tenants || []).map((t) => [t.tenantid, t]));
const dispatched = (bookings || []).filter((b) => b.assignedmileruserid || b.consignmentid);
const rows = dispatched.map((b) => {
const customer = customerMap.get(b.appcustomerid);
const miler = milerMap.get(b.assignedmileruserid);
const tenant = tenantMap.get(b.tenantid);
const charge = b.serviceoptions?.[0]?.estimatedprice;
return {
orderheaderid: b.bookingid,
deliveryid: b.bookingid,
orderid: b.bookingno || `#${b.bookingid}`,
consignmentid: b.consignmentid,
tenantid: b.tenantid,
tenantname: tenant?.tenantname || '',
tenantsuburb: '',
applocation: '',
tenantadress: tenant?.primaryemail || '',
locationname: tenant?.tenantname || '',
locationsuburb: '',
// Was hardcoded '' — Dispatch.js's kitchen markers read this as the
// pickup business name (`o.pickupcustomer || o.kitchen_key || 'Unknown'`,
// Dispatch.js:1660), and no booking on this API carries a `kitchen_key`
// field at all, so every kitchen pin fell through to the literal string
// 'Unknown' — rendered as a "U" marker whose hover/popup then showed
// "Unknown". The tenant IS the kitchen for a B2B booking (same value
// already used for tenantname/locationname above), so reuse it here.
pickupcustomer: tenant?.tenantname || '',
pickupcontactno: '',
Pickupaddress: b.pickupaddress || '',
pickupaddress: b.pickupaddress || '',
pickuplocation: b.pickupaddress || '',
pickupsuburb: '',
deliverycustomer: customer?.firstname || customer?.name || (b.appcustomerid ? `Customer #${b.appcustomerid}` : ''),
deliverycontactno: customer?.phone || customer?.contactno || '',
deliveryaddress: b.deliveryaddress || '',
deliverylocation: b.deliveryaddress || '',
deliverysuburb: '',
ridername: miler?.displayname || (b.assignedmileruserid ? `Rider #${b.assignedmileruserid}` : ''),
userid: b.assignedmileruserid,
// GET /admin/milers/:id/notify (and block/assign-vehicle) key off
// milerprofileid, not the userid stored on the booking — confirmed
// live (a booking's assignedmileruserid matches a miler's `userid`
// field, which 404s against /admin/milers/:id; milerprofileid is the
// real primary key of that resource).
milerprofileid: miler?.milerprofileid,
ridercontact: miler?.phone || '',
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
// No route-plan data source (step order/transit time/cumulative km were
// computed by the old jupiter backend from the dispatch optimiser's
// output, not stored on a booking/consignment) — left undefined so the
// UI's own "—" fallbacks render instead of a fabricated number.
transitminutes: undefined,
cumulativekms: undefined,
step: undefined,
// No road-distance field on a booking — approximated as a straight
// line between pickup and delivery coordinates.
kms: haversineKm(b.pickuplatitude, b.pickuplongitude, b.deliverylatitude, b.deliverylongitude),
pickuplatitude: b.pickuplatitude,
pickuplongitude: b.pickuplongitude,
deliverylatitude: b.deliverylatitude,
deliverylongitude: b.deliverylongitude,
deliverycharges: charge,
deliveryamt: charge,
deliveryamount: charge,
Quantity: b.parcels?.length || 0,
quantity: b.parcels?.length || 0,
collectionamt: undefined,
notes: b.notes || '',
deliverytype: customer ? 'B' : 'C',
orderdate: b.createdat,
deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || b.updatedat,
// ⚠ NOT a real assignment time. The Doormile bookings feed has no
// assignment timestamp (the true one lives on `bookingassignments`,
// reachable only per-booking via GET /admin/bookings/:id/track), so this
// is the booking's last-modified column. It moves every time ANYTHING
// touches the row — status change, parcel scan, payment, pickup-complete.
//
// **Never bucket or group by this field.** Dispatch.js and deliveries.js
// used to bucket their Morning/Afternoon/Evening batches on it, which
// meant an order re-stamped during the evening silently jumped out of the
// batch it was actually assigned to and into whichever window contained
// the current clock time — the same orders appearing under Afternoon and
// then Evening on the same day. Both now bucket on `orderdate` (this
// row's `createdat`, immutable) instead — see dispatch/CLAUDE.md §1's
// table for why `expecteddeliverytime` was also tried and rejected (it's
// the promised delivery slot, not the wave the order was placed in). It
// remains fine to DISPLAY assigntime as a "last updated" stamp, which is
// all the reports use it for.
assigntime: b.updatedat,
// The consignment's status WINS when there is one. That is the record
// the rider app and the Update Status dialog both advance; the booking's
// status is frozen at Converted_To_Consignment from pickup onwards.
// Falls back to the booking whenever the consignment is absent or carries
// nothing status-shaped — never invents a state.
orderstatus: mapBookingStatusToDeliveryStatus(consignmentStatusFor(b, consignmentMap) ?? b.status),
droplat: b.deliverylatitude,
droplon: b.deliverylongitude
};
});
// Apply the requested date range to the booking's CREATION day. This has to
// agree with what the batch bucketing reads (Dispatch.js's
// BATCH_TIME_FIELD / deliveries.js's BATCH_TIME_KEYS, both `orderdate`) —
// filtering on one field while bucketing on another is how you get a row
// that is counted for the day but belongs to no batch in it.
//
// parseDoormileTimestamp, not bare dayjs(): some Doormile timestamps carry a
// false trailing Z, and near midnight an unstripped one shifts the row +5:30
// into the next calendar day, dropping it from the selected date.
//
// A missing/blank bound means "unbounded on that side", which preserves the
// old behaviour for any caller that doesn't pass real dates.
//
// ⛔ An "activity" basis — also admitting a row whose `assigntime`
// (== `updatedat`) falls in the window — was tried and REVERTED. It let an
// order created yesterday evening and merely touched today onto today's
// board, but batch bucketing reads `orderdate`, so that row landed in
// Evening Batch. The live result was "Evening 6" at 10:41 in the morning on a
// day with no orders created at all. Admitting a row on one timestamp while
// bucketing it on another cannot produce an honest batch count; if
// carried-over work needs to be visible it needs its own bucket, not a
// time-of-day wave it does not belong to.
const inRange = (row) => {
if (!startdate && !enddate) return true;
const t = parseDoormileTimestamp(row.orderdate);
if (!t.isValid()) return false;
const day = t.format('YYYY-MM-DD');
if (startdate && day < String(startdate)) return false;
if (enddate && day > String(enddate)) return false;
return true;
};
return {
rows: rows.filter(inRange),
// 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
};
};
// Backs fetchCountAPI so the chip counts are derived from the exact same
// source (and status mapping) as fetchDeliveries' rows — counting against
// the separately-guessed /admin/dashboard shape produced numbers that didn't
// match what the table actually showed.
const getDeliveryStatusCounts = async () => {
try {
const bookings = (await getBookings(1, 1000)) || [];
const dispatched = bookings.filter((b) => b.assignedmileruserid || b.consignmentid);
const counts = { total: dispatched.length };
dispatched.forEach((b) => {
const status = mapBookingStatusToDeliveryStatus(b.status);
counts[status] = (counts[status] || 0) + 1;
});
return counts;
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load delivery counts', 'error', 2000);
return {};
}
};
// ==============================|| fetchCountAPI (deliveries) ||============================== //
export const fetchCountAPI = async () => {
const data = await getDeliveryStatusCounts();
return {
total: data.total || 0,
uncoveredLength: data.pending || 0,
assignedLength: data.accepted || 0,
arrivedLength: data.arrived || 0,
pickedLength: data.picked || 0,
activeLength: data.active || 0,
coveredLength: data.delivered || 0,
cancelLength: data.cancelled || 0,
skippedLength: data.skipped || 0
};
};
// ==============================|| cancelDeliveryAPI (deliveries) ||============================== //
// jupiter2doormile.md §4 confirms cancel is its own booking-level action —
// POST /admin/bookings/:id/cancel — not a consignment status PUT. cancelFeed
// isn't part of the documented cancel body; sent as a best-effort extra field
// rather than silently dropped, since it's unconfirmed whether the server
// persists it.
export const cancelDeliveryAPI = async (selectedRow, cancelFeed) =>
cancelBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { reason: cancelFeed });
// ==============================|| getorderdetails (deliveries) ||============================== //
export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid);
// ==============================|| changeRiderAPI (deliveries) ||============================== //
// doormile-flow.md (confirmed current, authoritative) settles this: the body
// is { "mileruserid": <miler's userid> } — the previous guess here (`milerid`
// key, `milerprofileid` value) was wrong on both counts. Admin miler
// endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the
// one exception that wants userid instead — "different identity spaces on
// adjacent endpoints," per that doc's own wording. selectedRider comes
// straight from getMilers(), which carries both fields on the same object.
export const changeRiderAPI = async (selectedRider, selectedRow) =>
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { 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.
// 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.
//
// ---- The body ---------------------------------------------------------------
//
// This used to forward the dialog's WHOLE state object as the request body —
// the old jupiter shape (`orderstatus`, `deliveryid`, `orderheaderid`,
// `deliveryamt`, `cumulativekms`, `userid`). The endpoint wants one field
// called `status`, so every submit came back:
//
// PUT /admin/consignments/40/status → 400 {"status is required"}
//
// The status was in the payload the whole time, under the wrong name.
//
// The VALUE has to be translated too. The dialog's options are this page's own
// lifecycle keys (`delivered`, `cancelled`, …); the API speaks the booking enum
// (`Delivered`, `Cancelled`, …). Sending `delivered` where `Delivered` is
// expected is the same class of bug one layer down.
//
// Reverse of BOOKING_STATUS_TO_DELIVERY_STATUS, and deliberately NOT derived
// from it by inversion: that map is many-to-one (`pending_pickup` and
// `miler_assigned` both mean `pending`), so an automatic inversion would pick
// whichever happened to be last and silently write the wrong one.
const DELIVERY_STATUS_TO_BOOKING_STATUS = {
pending: 'Pending_Pickup',
accepted: 'Pickup_Scheduled',
picked: 'Converted_To_Consignment',
// The dialog offers "started", which this API has no separate state for — a
// consignment that has started IS out for delivery.
started: 'Out_for_Delivery',
active: 'Out_for_Delivery',
delivered: 'Delivered',
cancelled: 'Cancelled',
canceled: 'Cancelled'
};
export const updateDeliveryAPI = async (orderData) => {
const id = orderData.consignmentid ?? orderData.deliveryid;
const chosen = String(orderData.orderstatus || '').toLowerCase();
const status = DELIVERY_STATUS_TO_BOOKING_STATUS[chosen];
// `arrived` and `skipped` have no booking-status equivalent at all (the rider
// actions behind them — /miler/bookings/:id/reached and
// /miler/consignments/:id/skip — write no booking status). Refusing here with
// the reason is honest; guessing a near-enough status would set the wrong one
// on a real delivery.
if (!status) {
return {
success: false,
message: chosen
? `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.`
: 'Choose a status first.'
};
}
// Only `status` is sent. The dialog's kms / amount / notes have no field on
// this endpoint (see the note above), and this request 400s on validation —
// so posting the rest is at best ignored and at worst another rejection.
return updateConsignmentStatus(id, { status });
};
// ==============================|| getalltenants (tenants) ||============================== //
export const getalltenants = async () => {
try {
return (await getAdminTenants()) || [];
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return null;
}
};
// ==============================|| getallpricing (clientPricing) ||============================== //
// This is the client-facing "Pricing" page (tenant pricing rules, GET
// /admin/pricing) — not GET /admin/doormile-pricing, which is Doormile's own
// internal banding with no tenant concept. Previously pointed at
// getDoormilePricing(), so the page's "Tenant"/"Tenants Priced" columns never
// had real data behind them.
export const getallpricing = async () => {
try {
return (await getAdminPricing()) || [];
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return [];
}
};
// ==============================|| fetchAllRiders (riders) ||============================== //
export const fetchAllRiders = async () => {
try {
const milers = await getMilers();
return { details: milers || [], nextPage: undefined };
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load riders', 'error', 2000);
return { details: [], nextPage: undefined };
}
};
// ==============================|| getallridersummary (riders) ||============================== //
export const getallridersummary = async () => {
try {
const milers = (await getMilers()) || [];
// A miler has no `status` field — availabilitystatus is the real one
// (confirmed live: Available/Assigned/On_Pickup/Offline/Blocked).
// "Active" here means not Offline and not Blocked, matching riders.js's
// own tab filter. `available`/`onDelivery` are a KPI-card-friendly
// breakdown of that same "active" bucket — Available is free/idle,
// On Delivery covers both Assigned (accepted, not yet picked up) and
// On_Pickup (en route) — computed separately so an unrecognised status
// value still falls into "active" (unchanged behaviour) without also
// silently inflating one of the two new buckets.
const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
const available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length;
const onDelivery = milers.filter((m) => ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
return { total: milers.length, active, inactive: milers.length - active, available, onDelivery };
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000);
return { total: 0, active: 0, inactive: 0, available: 0, onDelivery: 0 };
}
};
// ==============================|| getreportsummary (orders summary)||============================== //
// appId (queryKey[0]) was previously discarded here — the LocationAutocomplete
// zone picker on the Orders Summary page changed the header text but never
// the actual request. GET /admin/reports does accept a hubid param (see
// getReports in doormileApi.js) — hubs are this API's zone-equivalent (see
// LocationAutocomplete's own comment) — so pass appId through as that.
export const getreportsummary = async ({ queryKey }) => {
const [appId, tenantid, locationid, startdate, enddate] = queryKey;
try {
return (await getReports(startdate, enddate, tenantid, locationid, appId || undefined)) || {};
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load the report summary', 'error', 2000);
return {};
}
};
// ==============================|| getreportlocationsummary (orders summary)||============================== //
// Confirmed against jupiter2doormile.md (Status: Done) as the replacement for
// jupiter's getlocationsummary — GET /admin/locations/summary.
export const getreportlocationsummary = async ({ queryKey }) => {
const [, tenantid, locationid, startdate, enddate] = queryKey;
try {
return (await getLocationsSummary(tenantid, locationid, startdate, enddate)) || [];
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load the location summary', 'error', 2000);
return [];
}
};
// ==============================|| fetchRidersSummary (riders summary)||============================== //
// No per-rider performance summary endpoint in the new API.
// Confirmed against jupiter2doormile.md (Status: Done) as the replacement
// for jupiter's getridersummary — GET /admin/milers/summary. Per-rider row
// field names aren't documented anywhere; this passes the response through
// as-is and lets the page's own rendering degrade gracefully on unknown keys.
export const fetchRidersSummary = async ({ queryKey }) => {
const [, appId, startdate, enddate] = queryKey;
try {
const data = await getMilerSummary(appId || undefined, startdate, enddate);
return Array.isArray(data) ? data : data ? [data] : [];
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load the riders summary', 'error', 2000);
return [];
}
};
// ==============================|| fetchRidersLogs (RiderLogs)||============================== //
// No rider login/checkout audit-log endpoint in the new API.
// Was a permanent [] stub ("no rider login/checkout audit-log endpoint") —
// that was true for the jupiter audit-log concept this was originally named
// after, but jupiter2doormile.md confirms (Status: Done) that
// GET /admin/milers/summary carries live position (currentlatitude/
// currentlongitude/lastpingat per that doc) for every rider at a zone. This
// feeds Dispatch.js's live map pins and "All Active Routes" view, which were
// both silently dead with the old stub.
//
// GET /admin/milers/summary confirmed live: { milerprofileid, userid,
// displayname, phone, availabilitystatus, defaultvehicletype, hubid,
// hubname, rating, onduty, dutystartedat, currentlatitude, currentlongitude,
// lastlocationupdatedat, lastpingat, assigned, accepted, rejected,
// completed, cancelled, delivered, riderkms, ridercharges } — no `status`,
// `contactno`, or current-order-id field exists at all. `id` is exposed as
// an alias of milerprofileid, which is what every /admin/milers/:id/* route
// (notify, block, assign-vehicle, logs) actually keys off — not userid.
export const fetchRidersLogs = async ({ queryKey } = {}) => {
const [appId] = queryKey || [];
try {
const data = await getMilerSummary(appId || undefined);
const riders = Array.isArray(data) ? data : data?.riders || data?.milers || [];
return riders.map((r) => ({
id: r.milerprofileid,
milerprofileid: r.milerprofileid,
userid: r.userid,
username: r.displayname,
status: r.availabilitystatus,
contactno: r.phone,
orderid: undefined,
logdate: r.lastpingat ?? r.lastlocationupdatedat,
latitude: r.currentlatitude,
longitude: r.currentlongitude
}));
} catch {
// Polled every 1s by Dispatch.js's live map (RIDER_LOG_POLL_MS) — a toast
// here would fire once a second on any outage. Fail silently; the map
// just stops updating rider pins until the next successful poll.
return [];
}
};
// ==============================|| getusers (viewProfile)||============================== //
export const getusers = async () => {
try {
return (await getProfile()) || {};
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load profile', 'error', 2000);
return {};
}
};
// ==============================|| getallriders (order)||============================== //
export const getallriders = async () => {
try {
return (await getMilers()) || [];
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load riders', 'error', 2000);
return [];
}
};