updates on the ui changes and changed into doormile
This commit is contained in:
268
src/lib/assistant/assignActions.js
Normal file
268
src/lib/assistant/assignActions.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { getMilers, assignMilerToBooking } from '@/api/doormile/endpoints';
|
||||
import { notifyMiler } from '@/api/doormile/endpoints';
|
||||
|
||||
const normMilerName = (n) => (n || '').toString().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 };
|
||||
};
|
||||
|
||||
|
||||
// ==============================|| 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 notifyMiler(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 notifyMiler(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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user