1068 lines
53 KiB
JavaScript
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 [];
|
|
}
|
|
};
|