253 lines
11 KiB
JavaScript
253 lines
11 KiB
JavaScript
import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi';
|
|
import { buildMilerLookup, notifyRider } from 'pages/api/api';
|
|
|
|
// ==============================|| Doormile AI — assigning a rider ||============================== //
|
|
//
|
|
// Fourth write capability. Two endpoints, and which one is correct depends
|
|
// entirely on how many orders are being assigned:
|
|
//
|
|
// ONE order → POST /admin/bookings/:id/assign-miler
|
|
// MANY orders → POST /hub/bookings/batch-assign
|
|
//
|
|
// These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only
|
|
// place stops get ordered" — after assigning it sends each affected rider's
|
|
// whole active set to the route optimiser and writes step, per-leg distance and
|
|
// ETA back onto `bookingassignments`. Assigning ten orders with ten single
|
|
// calls leaves every route unsequenced and riders choosing their own order.
|
|
//
|
|
// ---- The two rider IDs -----------------------------------------------------
|
|
//
|
|
// `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify`
|
|
// keys off a **milerprofileid**. Different identity spaces on adjacent
|
|
// endpoints — doormile-flow.md calls this out by name. Getting it wrong fails
|
|
// quietly in both directions: the assign 404s, or the rider is never told.
|
|
// `buildMilerLookup` is the bridge, and it is the SAME one orders.js already
|
|
// uses for exactly this translation. Don't grow a second lookup here.
|
|
//
|
|
// ---- Assignment is normally automatic --------------------------------------
|
|
//
|
|
// Creating a booking publishes `booking.assignment_requested`; a worker finds a
|
|
// rider within 10km via Redis GEO, scores them, and commits — retrying 5 times,
|
|
// 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a
|
|
// decision the backend is already making, which is why the flow re-reads the
|
|
// booking's current assignee before offering to change it.
|
|
|
|
export const ASSIGN_TRIGGER =
|
|
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
|
|
|
|
// Riders, plus the id bridge, in one call.
|
|
export const loadRiders = async () => {
|
|
const milers = (await getMilers()) || [];
|
|
return { milers, lookup: buildMilerLookup(milers) };
|
|
};
|
|
|
|
const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`;
|
|
|
|
// Available riders first — an operator picking by hand wants the ones who can
|
|
// actually take it at the top. Beyond that, alphabetical: any other ordering
|
|
// (nearest, least loaded) would need position data this list doesn't carry, and
|
|
// a proximity label nobody can stand behind is worse than none.
|
|
const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || ''));
|
|
|
|
export const riderOptions = (milers) =>
|
|
[...(milers || [])]
|
|
.sort((a, b) => {
|
|
const byAvail = Number(isAvailable(b)) - Number(isAvailable(a));
|
|
return byAvail || riderName(a).localeCompare(riderName(b));
|
|
})
|
|
.map((m) => ({
|
|
value: String(m.userid),
|
|
label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '),
|
|
record: m
|
|
}));
|
|
|
|
// Who currently holds this booking, resolved to a person rather than an id.
|
|
export const currentAssignee = (booking, lookup) =>
|
|
booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null;
|
|
|
|
export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null);
|
|
|
|
// ---- one order --------------------------------------------------------------
|
|
export const executeAssign = async (booking, rider) => {
|
|
const started = Date.now();
|
|
const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`;
|
|
const call = {
|
|
name: 'assignMilerToBooking',
|
|
target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`,
|
|
stats: `${bookingLabel} → ${riderName(rider)}`
|
|
};
|
|
|
|
try {
|
|
// mileruserid, NOT milerprofileid. See the note at the top of this file.
|
|
const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) });
|
|
const duration = `${Date.now() - started}ms`;
|
|
|
|
if (res && res.success === false) {
|
|
return {
|
|
ok: false,
|
|
message: res.message || 'The server refused the assignment.',
|
|
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
|
|
};
|
|
}
|
|
|
|
const sourceCalls = [{ ...call, duration, status: 'complete' }];
|
|
|
|
// CLAUDE.md §9: any mutation that affects a rider is followed by a push.
|
|
// It is deliberately NOT allowed to fail the assignment — the order IS
|
|
// assigned at this point, and reporting otherwise would be a lie.
|
|
// Whether the push ACTUALLY went out, not whether it could have been
|
|
// attempted. This used to be reported as `Boolean(rider.milerprofileid)`
|
|
// — i.e. "this rider has an id, so assume they were told" — which is a
|
|
// different claim entirely. Caught live: notify returned 400 and the
|
|
// assistant still said "The rider has been notified."
|
|
//
|
|
// The assignment itself is unaffected; it had already landed. But a
|
|
// dispatcher who believes a rider was pinged does not follow up, and this
|
|
// bot's whole contract is that it never states something it has not
|
|
// confirmed.
|
|
let notified = false;
|
|
|
|
if (rider.milerprofileid) {
|
|
try {
|
|
await notifyRider(rider.milerprofileid);
|
|
notified = true;
|
|
sourceCalls.push({
|
|
name: 'notifyRider',
|
|
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
|
status: 'complete',
|
|
stats: 'rider notified'
|
|
});
|
|
} catch (err) {
|
|
sourceCalls.push({
|
|
name: 'notifyRider',
|
|
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
|
status: 'error',
|
|
errorMessage: err.message || 'notification failed'
|
|
});
|
|
}
|
|
} else {
|
|
// No profile id means no push is possible — say so rather than letting
|
|
// the operator assume the rider's phone buzzed.
|
|
sourceCalls.push({
|
|
name: 'notifyRider',
|
|
target: '/admin/milers/:id/notify',
|
|
status: 'error',
|
|
errorMessage: 'this rider has no milerprofileid, so no notification could be sent'
|
|
});
|
|
}
|
|
|
|
return { ok: true, rider, bookingLabel, notified, sourceCalls };
|
|
} catch (err) {
|
|
// doormileAxios rejects with the response BODY; the status rides on
|
|
// err.httpStatus.
|
|
const status = err.httpStatus;
|
|
const message = status
|
|
? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${
|
|
err.message ? ` — ${err.message}` : ''
|
|
}. Nothing changed.`
|
|
: `${err.message || 'The request failed'} — nothing changed.`;
|
|
return {
|
|
ok: false,
|
|
message,
|
|
sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }]
|
|
};
|
|
}
|
|
};
|
|
|
|
// ---- repeat a run, keeping each order with the rider who ran it last --------
|
|
//
|
|
// A repeated run is the SAME drops to the SAME doors. The rider who did them
|
|
// yesterday already knows the buzzer, the gate code and which side of the
|
|
// building to park on, so re-deriving an assignment from scratch throws away
|
|
// the one piece of routing knowledge the previous day produced.
|
|
//
|
|
// Deliberately built on /admin/bookings/:id/assign-miler, one call per order,
|
|
// rather than the hub batch endpoint: batch-assign lets the BACKEND choose
|
|
// riders, which is the opposite of the intent here, and it is refused to every
|
|
// non-hub login anyway (403, confirmed live).
|
|
//
|
|
// The trade-off this accepts: assigning individually does not sequence a
|
|
// rider's stops. Yesterday's run was already sequenced for these same drops,
|
|
// so the ordering is not arbitrary — but it is not recomputed either, and the
|
|
// caller states that rather than implying a fresh optimisation.
|
|
export const executeRepeatAssign = async (createdPairs, rows) => {
|
|
const started = Date.now();
|
|
|
|
// Only rows whose source order actually had a rider. A blank one is not a
|
|
// failure — yesterday's copy was never assigned either.
|
|
const targets = (createdPairs || [])
|
|
.map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null }))
|
|
.filter((t) => t.mileruserid != null);
|
|
|
|
if (!targets.length) {
|
|
return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] };
|
|
}
|
|
|
|
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
|
const assignedRiders = new Set();
|
|
const failures = [];
|
|
let assigned = 0;
|
|
|
|
// Sequential on purpose. These are writes against real dispatch records, and
|
|
// firing a burst of them concurrently makes a partial failure much harder to
|
|
// report accurately — which order did not land, and to whom.
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for (const t of targets) {
|
|
try {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
|
|
assigned += 1;
|
|
assignedRiders.add(String(t.mileruserid));
|
|
} catch (err) {
|
|
failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` });
|
|
}
|
|
}
|
|
|
|
const sourceCalls = [
|
|
{
|
|
name: 'assignMilerToBooking',
|
|
target: 'POST /admin/bookings/:id/assign-miler',
|
|
duration: `${Date.now() - started}ms`,
|
|
status: failures.length ? 'error' : 'complete',
|
|
stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`,
|
|
errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined
|
|
}
|
|
];
|
|
|
|
// One push per rider, not per order — a rider getting ten of yesterday's
|
|
// drops back should feel one buzz, not ten.
|
|
let notified = 0;
|
|
if (lookup) {
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for (const userid of assignedRiders) {
|
|
const rider = lookup.byUserId.get(userid);
|
|
if (rider?.milerprofileid) {
|
|
try {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await notifyRider(rider.milerprofileid);
|
|
notified += 1;
|
|
} catch {
|
|
// Notification failure never fails the assignment — the order IS
|
|
// assigned by this point. It is reported, not swallowed.
|
|
}
|
|
}
|
|
}
|
|
sourceCalls.push({
|
|
name: 'notifyRider',
|
|
target: 'POST /admin/milers/:id/notify',
|
|
status: notified === assignedRiders.size ? 'complete' : 'error',
|
|
stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified`
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: assigned > 0,
|
|
assigned,
|
|
skipped: (createdPairs || []).length - targets.length,
|
|
failures,
|
|
notified,
|
|
riders: assignedRiders.size,
|
|
sourceCalls
|
|
};
|
|
};
|