updates on the fix

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

View File

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