updates on the ui changes and the bot changes
This commit is contained in:
@@ -98,4 +98,5 @@ export const someFetch = async () => {
|
||||
|
||||
- Several `api.js` functions ignore most of their destructured `queryKey`/arguments — the new endpoint doesn't accept those filters. This is deliberate degradation, not a bug; see §3.
|
||||
- A handful of pages (`createorder1.js`, `multipleOrders.js`) synthesize fallback data client-side (e.g. a fixed 09:00–21:00 delivery-slot window) because the endpoint that used to supply it no longer exists. Look for the comment explaining why before "fixing" it.
|
||||
- **`updateDeliveryAPI` refuses half the Deliveries dialog's options, on purpose.** It writes to `PUT /admin/consignments/:id/status`, which speaks the **consignment** enum — not the booking enum they look like. `Picked` maps to `Converted_To_Consignment`, which names the *moment a booking becomes a consignment*; a consignment cannot be set to it, so choosing Picked did nothing and the row kept showing the consignment's real state. `Pending` and `Accepted` describe a booking *before* pickup, which a consignment can never return to. Only `Out_for_Delivery`, `Delivered` and `Cancelled` are sent; everything else is refused with a reason and **nothing goes on the wire**. If the real consignment enum is ever captured, widen the map — don't guess a value.
|
||||
- `notifyRider` / `notifyMiler` now take a **miler profile ID**, not an FCM token — the new API's `/admin/milers/:id/notify` looks the device token up server-side. Don't pass a token here.
|
||||
|
||||
@@ -714,6 +714,13 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
// 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),
|
||||
// Which record the status above came from, and what it said. A booking
|
||||
// freezes at Converted_To_Consignment the moment it is picked up, so a
|
||||
// row can legitimately read "Active" while GET /admin/bookings still says
|
||||
// Converted_To_Consignment — which looks exactly like a bug unless the UI
|
||||
// can say where the value came from.
|
||||
consignmentstatus: consignmentStatusFor(b, consignmentMap),
|
||||
statusfromconsignment: consignmentStatusFor(b, consignmentMap) != null,
|
||||
droplat: b.deliverylatitude,
|
||||
droplon: b.deliverylongitude
|
||||
};
|
||||
@@ -858,12 +865,30 @@ export const changeRiderAPI = async (selectedRider, selectedRow) =>
|
||||
// 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.
|
||||
// ⚠ These are CONSIGNMENT statuses, not booking statuses. The endpoint is
|
||||
// `PUT /admin/consignments/:id/status`, and the two enums are not the same
|
||||
// vocabulary even though they overlap on Delivered and Cancelled.
|
||||
//
|
||||
// This map used to include the booking's pre-pickup states, and they were all
|
||||
// nonsense to send here:
|
||||
//
|
||||
// picked → 'Converted_To_Consignment' — that names the MOMENT a booking
|
||||
// becomes a consignment. A consignment cannot be set to it; it
|
||||
// already is one. Choosing "Picked" in the dialog therefore did
|
||||
// nothing and the row kept showing whatever the consignment
|
||||
// really was, which is what "I set it to picked and it still says
|
||||
// Active" was.
|
||||
// pending → 'Pending_Pickup'
|
||||
// accepted → 'Pickup_Scheduled' — both describe a booking BEFORE
|
||||
// pickup. A consignment only exists after it, so it can never go
|
||||
// back to either.
|
||||
//
|
||||
// What a consignment can actually be set to, per doormile-flow.md §5–6: it is
|
||||
// created at pickup-complete already Out_for_Delivery (hyperlocal) or routed
|
||||
// via a hub, then delivered, skipped, or cancelled.
|
||||
const DELIVERY_STATUS_TO_CONSIGNMENT_STATUS = {
|
||||
// The dialog offers "started"; a consignment that has started IS out for
|
||||
// delivery — this API has no separate state for it.
|
||||
started: 'Out_for_Delivery',
|
||||
active: 'Out_for_Delivery',
|
||||
delivered: 'Delivered',
|
||||
@@ -871,10 +896,24 @@ const DELIVERY_STATUS_TO_BOOKING_STATUS = {
|
||||
canceled: 'Cancelled'
|
||||
};
|
||||
|
||||
// Why a status can't be set, when it can't. Specific beats generic: "Picked
|
||||
// can't be set" is useless next to "it's already a consignment, which is what
|
||||
// picked means".
|
||||
const UNSETTABLE_REASON = {
|
||||
picked: 'this order is already a consignment — which is exactly what "picked" means. There is no earlier state to set it back to.',
|
||||
pending:
|
||||
'a consignment only exists after pickup, so it can’t go back to Pending — that describes a booking before the parcel was collected.',
|
||||
accepted:
|
||||
'a consignment only exists after pickup, so it can’t go back to Accepted — that describes a booking before the parcel was collected.',
|
||||
arrived: 'the rider action behind Arrived (POST /miler/bookings/:id/reached) writes no consignment status, so there is nothing to set.',
|
||||
skipped:
|
||||
'Skipped is recorded by the rider (POST /miler/consignments/:id/skip) as an attempt count, not as a status this endpoint can write.'
|
||||
};
|
||||
|
||||
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];
|
||||
const status = DELIVERY_STATUS_TO_CONSIGNMENT_STATUS[chosen];
|
||||
|
||||
// `arrived` and `skipped` have no booking-status equivalent at all (the rider
|
||||
// actions behind them — /miler/bookings/:id/reached and
|
||||
@@ -882,11 +921,11 @@ export const updateDeliveryAPI = async (orderData) => {
|
||||
// the reason is honest; guessing a near-enough status would set the wrong one
|
||||
// on a real delivery.
|
||||
if (!status) {
|
||||
if (!chosen) return { success: false, message: 'Choose a status first.' };
|
||||
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.'
|
||||
message:
|
||||
UNSETTABLE_REASON[chosen] || `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,30 @@ A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row arra
|
||||
|
||||
Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
|
||||
|
||||
### Repeat Runs — `repeatRuns.js` / `repeatFlow.js`
|
||||
|
||||
"Same orders as yesterday." One question (which day), then a pass, then the usual gate.
|
||||
|
||||
**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply.
|
||||
|
||||
**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination:
|
||||
|
||||
| Trap | Seen on |
|
||||
|---|---|
|
||||
| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key |
|
||||
| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` |
|
||||
| pickup pincode no longer served | CityGate refuses at the middleware, before the handler |
|
||||
|
||||
Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale.
|
||||
|
||||
**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key.
|
||||
|
||||
**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number.
|
||||
|
||||
**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders.
|
||||
|
||||
Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round".
|
||||
|
||||
### Assigning a rider — `assignActions.js` / `assignFlow.js`
|
||||
|
||||
The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run.
|
||||
@@ -238,7 +262,7 @@ The fourth write. Reached three ways: automatically after a single create, from
|
||||
|
||||
Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed.
|
||||
|
||||
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
|
||||
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -141,7 +141,9 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={
|
||||
message.form.kind === 'confirmBulk'
|
||||
message.form.confirmLabel
|
||||
? message.form.confirmLabel
|
||||
: message.form.kind === 'confirmBulk'
|
||||
? `Create ${message.form.draft?.rows?.length || 0} orders`
|
||||
: message.form.kind === 'confirmOrder'
|
||||
? 'Create order'
|
||||
@@ -156,6 +158,16 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
|
||||
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
|
||||
{/* A second, wider commit — used when rows were excluded by
|
||||
default but including them is a legitimate choice. */}
|
||||
{message.form.altDraft && (
|
||||
<Button
|
||||
label={message.form.altLabel || 'Include everything'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.altDraft)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { startOrderFlow, answerOrderStep } from '../orderFlow';
|
||||
import { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '../bulkFlow';
|
||||
import { startAssignFlow, answerAssignStep } from '../assignFlow';
|
||||
import { executeAssign, executeBatchAssign, describeRider } from '../assignActions';
|
||||
import { startRepeatFlow, answerRepeatStep } from '../repeatFlow';
|
||||
import { buildRepeatRun, describeDay } from '../repeatRuns';
|
||||
import { getPageContext } from './pageContext';
|
||||
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
|
||||
import AIWelcome from './AIWelcome';
|
||||
@@ -280,6 +282,116 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
// The assign conversation's end. Two outcomes: the operator chose to keep the
|
||||
// rider the backend already picked (nothing to send), or they picked someone
|
||||
// and get the usual one-press gate.
|
||||
// Everything after "which day" — resolving customers, the drift check,
|
||||
// re-quoting at today's tariff, the already-repeated guard. Narrated into one
|
||||
// message like the bulk file's locate pass, because none of it is a question.
|
||||
const finishRepeat = useCallback(
|
||||
async (draft) => {
|
||||
bulkStop.current = false;
|
||||
const liveId = pushLive({
|
||||
sender: 'assistant',
|
||||
text: `Checking ${describeDay(draft.day)}’s orders…`,
|
||||
liveStop: true
|
||||
});
|
||||
|
||||
const run = await buildRepeatRun(draft.day, {
|
||||
shouldStop: () => bulkStop.current,
|
||||
onProgress: (p) => patch(liveId, { text: `Pricing at today’s tariff… ${Math.min(p.done + 1, p.total)} of ${p.total}` })
|
||||
});
|
||||
|
||||
patch(liveId, { text: `Checked ${run.total} order${run.total === 1 ? '' : 's'} from ${describeDay(draft.day)}.`, liveStop: false });
|
||||
|
||||
// Nothing NEW, but everything already exists today. That is not a dead
|
||||
// end — running the same round twice in a day (a lunch wave and a dinner
|
||||
// wave to the same customers) is ordinary work. The guard's job is to make
|
||||
// sure it is deliberate, not to refuse it, so this warns loudly and still
|
||||
// offers the button.
|
||||
if (!run.valid.length && run.already.length) {
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `All ${run.already.length} of ${describeDay(draft.day)}’s orders have already been created today.`,
|
||||
list: {
|
||||
title: 'Already created today',
|
||||
numbered: false,
|
||||
items: run.already.slice(0, 12).map((r) => ({
|
||||
label: `${r.customer_name} · ${r.customer_phone}`,
|
||||
meta: r.deliveryaddress
|
||||
}))
|
||||
},
|
||||
detail: `Creating them again produces a SECOND set of orders to the same customers — a second real rider to each door. Only do it if that is what you mean.`,
|
||||
form: {
|
||||
kind: 'confirmBulk',
|
||||
status: 'open',
|
||||
draft: { rows: run.already, invalid: run.drifted, repeatOf: draft.day },
|
||||
confirmLabel: `Create all ${run.already.length} again`
|
||||
}
|
||||
});
|
||||
setFlow(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run.valid.length) {
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: {
|
||||
tone: STATUS.muted,
|
||||
iconKey: 'info',
|
||||
title: 'Nothing to repeat',
|
||||
body: run.drifted.length
|
||||
? `None can be repeated — the first problem is that ${run.drifted[0].error}.`
|
||||
: 'That day has no repeatable orders.'
|
||||
},
|
||||
downloadRows: run.drifted.length ? run.drifted : undefined
|
||||
});
|
||||
setFlow(null);
|
||||
return;
|
||||
}
|
||||
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `${run.valid.length} order${run.valid.length === 1 ? '' : 's'} ready to repeat.`,
|
||||
list: {
|
||||
title: 'Will be created',
|
||||
numbered: false,
|
||||
items: run.valid.slice(0, 12).map((r) => ({
|
||||
label: `${r.customer_name} · ${r.customer_phone}`,
|
||||
meta: `₹${Number(r.finalprice).toFixed(2)}${
|
||||
r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice)
|
||||
? ` (was ₹${Number(r.previousPrice).toFixed(2)})`
|
||||
: ''
|
||||
} · ${r.deliveryaddress}`
|
||||
}))
|
||||
},
|
||||
// Every line here is a row NOT being created, and the reason why.
|
||||
detail: [
|
||||
run.valid.length > 12 ? `Showing the first 12 of ${run.valid.length}.` : null,
|
||||
run.already.length ? `${run.already.length} already created today — left out so nobody is booked twice.` : null,
|
||||
run.drifted.length ? `${run.drifted.length} can’t be repeated (${run.drifted[0].error}).` : null,
|
||||
run.unpriced.length ? `${run.unpriced.length} couldn’t be priced at today’s tariff.` : null,
|
||||
run.changed.length
|
||||
? `${run.changed.length} price${run.changed.length === 1 ? '' : 's'} changed since ${describeDay(draft.day)}.`
|
||||
: null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
downloadRows: run.drifted.length ? run.drifted : undefined,
|
||||
form: {
|
||||
kind: 'confirmBulk',
|
||||
status: 'open',
|
||||
draft: { rows: run.valid, invalid: run.drifted, repeatOf: draft.day },
|
||||
// Rows already created today are excluded by default — but repeating
|
||||
// a round twice in a day is legitimate, so there is a way to include
|
||||
// them without starting over.
|
||||
altLabel: run.already.length
|
||||
? `Include the ${run.already.length} already created today (${run.valid.length + run.already.length} total)`
|
||||
: undefined,
|
||||
altDraft: run.already.length ? { rows: [...run.valid, ...run.already], invalid: run.drifted, repeatOf: draft.day } : undefined
|
||||
}
|
||||
});
|
||||
},
|
||||
[push, pushLive, patch]
|
||||
);
|
||||
|
||||
const finishAssign = useCallback(
|
||||
async (draft) => {
|
||||
const label = draft.booking?.bookingno || `#${draft.booking?.bookingid}`;
|
||||
@@ -424,6 +536,14 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
|
||||
// An order flow answers through this same path when the step is
|
||||
// free text; `select` steps come in through chooseStep instead.
|
||||
if (flow.kind === 'repeatRun') {
|
||||
const next = await answerRepeatStep(flow, question);
|
||||
setFlow(next.flow);
|
||||
await renderFlowStep(next, finishRepeat);
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.kind === 'assignRider') {
|
||||
const next = await answerAssignStep(flow, question);
|
||||
setFlow(next.flow);
|
||||
@@ -478,6 +598,14 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
// The form version was replaced on explicit request; asking one
|
||||
// question at a time reads better and matches how an operator would
|
||||
// dictate a record.
|
||||
if (result.form?.kind === 'repeatRun') {
|
||||
const started = await startRepeatFlow();
|
||||
setFlow(started.flow);
|
||||
await renderFlowStep(started, finishRepeat);
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.form?.kind === 'assignRider') {
|
||||
const started = await startAssignFlow(result.form.booking);
|
||||
setFlow(started.flow);
|
||||
@@ -567,7 +695,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, push]
|
||||
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, finishRepeat, push]
|
||||
);
|
||||
|
||||
const copyMessage = useCallback((m) => {
|
||||
@@ -726,7 +854,9 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
|
||||
// ---- bulk orders ----
|
||||
if (message.form.kind === 'confirmBulk') {
|
||||
const { rows, invalid, ...shared } = message.form.draft;
|
||||
// `values` is whichever button was pressed — the default draft, or the
|
||||
// alternative that includes today's existing rows.
|
||||
const { rows, invalid, ...shared } = values?.rows ? values : message.form.draft;
|
||||
const res = await executeCreateBulk(rows, shared);
|
||||
// A bulk import is normally a PARTIAL result, so the outcome is reported
|
||||
// per row rather than as a single success/failure. Without that, the only
|
||||
@@ -856,8 +986,16 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, flowStep: { ...m.flowStep, __answered: true } } : m)));
|
||||
setIsSending(true);
|
||||
try {
|
||||
const answer = flow.kind === 'createBulk' ? answerBulkStep : flow.kind === 'assignRider' ? answerAssignStep : answerOrderStep;
|
||||
const finish = flow.kind === 'createBulk' ? finishBulk : flow.kind === 'assignRider' ? finishAssign : finishOrder;
|
||||
// A table, not a ternary chain: with four conversations the chain became
|
||||
// unreadable, and mispairing an answer with the wrong finisher sends a
|
||||
// reply into the wrong conversation.
|
||||
const BY_KIND = {
|
||||
createBulk: [answerBulkStep, finishBulk],
|
||||
assignRider: [answerAssignStep, finishAssign],
|
||||
repeatRun: [answerRepeatStep, finishRepeat],
|
||||
createOrder: [answerOrderStep, finishOrder]
|
||||
};
|
||||
const [answer, finish] = BY_KIND[flow.kind] || BY_KIND.createOrder;
|
||||
const next = await answer(flow, value, option);
|
||||
setFlow(next.flow);
|
||||
await renderFlowStep(next, finish);
|
||||
@@ -868,7 +1006,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[flow, push, renderFlowStep, finishBulk, finishOrder, finishAssign]
|
||||
[flow, push, renderFlowStep, finishBulk, finishOrder, finishAssign, finishRepeat]
|
||||
);
|
||||
|
||||
// Stops the long locate/price pass. The flag is a ref for the reason
|
||||
|
||||
@@ -190,7 +190,10 @@ export const executeCreateBulk = async (rows, shared) => {
|
||||
// Recorded BEFORE the request, not after: a timed-out submit is the case that
|
||||
// most needs the warning, and it never reaches a success handler.
|
||||
submitted.add(rowSetFingerprint(rows));
|
||||
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, shared.__pickup));
|
||||
// A row may carry its OWN pickup. A bulk FILE shares one kitchen, but a
|
||||
// repeated day's run can span several tenants and locations — collapsing
|
||||
// those onto one shared pickup would silently re-address half the orders.
|
||||
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, r.__pickup ?? shared.__pickup));
|
||||
const batches = chunk(payloads, BULK_MAX);
|
||||
|
||||
const sourceCalls = [];
|
||||
|
||||
@@ -31,6 +31,7 @@ import { STATUS } from 'themes/dt/tokens';
|
||||
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
|
||||
import { CREATE_ORDER_TRIGGER } from './orderActions';
|
||||
import { ASSIGN_TRIGGER } from './assignActions';
|
||||
import { REPEAT_TRIGGER } from './repeatRuns';
|
||||
import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
|
||||
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
|
||||
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
|
||||
@@ -744,6 +745,19 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
|
||||
const MULTI_SPLIT = /\band\b|,|\+|&/i;
|
||||
|
||||
const INTENTS = [
|
||||
{
|
||||
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
|
||||
// contains "orders", so createBulkOrders and createOrder would otherwise
|
||||
// claim it and open a blank create instead of recalling the run.
|
||||
id: 'repeatRun',
|
||||
label: 'Repeat a past day’s orders — e.g. "repeat yesterday’s orders"',
|
||||
match: (text) => (REPEAT_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
headline: 'Let’s repeat a previous run.',
|
||||
form: { kind: 'repeatRun', status: 'open' },
|
||||
sourceCalls: []
|
||||
})
|
||||
},
|
||||
{
|
||||
// Ordered FIRST, ahead of createOrder: "create multiple orders" also
|
||||
// matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger
|
||||
|
||||
30
src/pages/nearle/assistant/repeatFlow.js
Normal file
30
src/pages/nearle/assistant/repeatFlow.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { findRecentRuns, describeDay } from './repeatRuns';
|
||||
import { startFlow, advanceFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a run ||============================== //
|
||||
//
|
||||
// One question: which day. Everything after it — resolving customers, the drift
|
||||
// check, re-quoting at today's tariff, the already-repeated guard — is a pass
|
||||
// the panel runs and narrates, exactly like the bulk file's locate/price phase.
|
||||
// None of it is a question, so none of it is a step.
|
||||
|
||||
export const REPEAT_STEPS = [
|
||||
{
|
||||
id: 'day',
|
||||
type: 'select',
|
||||
ask: 'Which day’s orders should I repeat?',
|
||||
options: async () => {
|
||||
const { runs } = await findRecentRuns();
|
||||
return runs.map((r) => ({
|
||||
value: r.day,
|
||||
label: `${describeDay(r.day)} — ${r.count} order${r.count === 1 ? '' : 's'}`,
|
||||
record: r
|
||||
}));
|
||||
},
|
||||
apply: (d, v, option) => ({ ...d, day: v, sourceCount: option?.record?.count })
|
||||
}
|
||||
];
|
||||
|
||||
export const startRepeatFlow = () => startFlow(REPEAT_STEPS, 'repeatRun', {});
|
||||
export const advanceRepeat = (flow) => advanceFlow(REPEAT_STEPS, flow);
|
||||
export const answerRepeatStep = (flow, raw, option) => answerFlowStep(REPEAT_STEPS, flow, raw, option);
|
||||
239
src/pages/nearle/assistant/repeatRuns.js
Normal file
239
src/pages/nearle/assistant/repeatRuns.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getAdminCustomers } from 'pages/api/doormileApi';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { groupForBookingStatus } from 'utils/orderStatusGroups';
|
||||
import { scanBookings } from './intents';
|
||||
import { cityGateFor } from './orderActions';
|
||||
import { priceBulkRows } from './bulkOrderActions';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a past day's run ||============================== //
|
||||
//
|
||||
// "I created ten orders yesterday; make the same ten today."
|
||||
//
|
||||
// This is the cheapest write in the assistant, and the reason is worth stating:
|
||||
// a booking already carries 15 of the 17 fields `buildOrderPayload` needs —
|
||||
// both addresses, both pincodes, BOTH SETS OF COORDINATES, the service tier,
|
||||
// the parcels. Only the recipient's name and phone are missing, and those come
|
||||
// from the `appcustomerid` → /admin/customers join the assistant already does.
|
||||
//
|
||||
// So a repeat needs NO geocoding. The ~1 lookup/second Nominatim throttle that
|
||||
// dominates the bulk-file flow does not apply here at all.
|
||||
//
|
||||
// ---- A booking is a snapshot, not a template --------------------------------
|
||||
//
|
||||
// Which is why every row goes through a drift check before it can be repeated.
|
||||
// These are not hypothetical; all three were found in one live page of 36
|
||||
// bookings:
|
||||
//
|
||||
// • `pickupaddress` can be ABSENT entirely (booking 57 carries the pincode
|
||||
// and coordinates but no address key at all) — repeating it blind sends an
|
||||
// empty pickup address.
|
||||
// • `tenantid` can be null (every `Customer_App` booking) — `Number(null)`
|
||||
// is 0, so the payload would claim tenant zero.
|
||||
// • a pickup pincode that was open when the order was placed may not be now,
|
||||
// and CityGate refuses at the middleware, before the handler runs.
|
||||
//
|
||||
// ---- Duplicate safety is INVERTED here --------------------------------------
|
||||
//
|
||||
// Everywhere else in this assistant, near-identical orders are an error to
|
||||
// prevent (`wasAlreadySubmitted`). A repeat run deliberately creates them, so
|
||||
// that guard would misfire on every single run. The question that actually
|
||||
// matters is different: *has this run already been repeated today?* The
|
||||
// endpoint has no idempotency key, so it is answered by looking: today's own
|
||||
// bookings are fingerprinted and any row already present is set aside rather
|
||||
// than booked twice.
|
||||
|
||||
export const REPEAT_TRIGGER = /\b(?:repeat|redo|re-?run)\b|\bsame\s+orders?\s+as\b|\bsame\s+as\s+(?:yesterday|last)\b/i;
|
||||
|
||||
// How far back a run can be recalled from. Beyond a week it stops being "the
|
||||
// usual round" and starts being archaeology.
|
||||
const LOOKBACK_DAYS = 7;
|
||||
|
||||
const dayOf = (value) => {
|
||||
const t = parseDoormileTimestamp(value);
|
||||
return t.isValid() ? t.format('YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
// A row is only worth repeating if it was a real order. Cancelled ones are
|
||||
// excluded — repeating a cancellation is never what "same as yesterday" means.
|
||||
const isRepeatable = (b) => groupForBookingStatus(b.status) !== 'cancelled';
|
||||
|
||||
// ---- which days have a run to repeat ---------------------------------------
|
||||
export const findRecentRuns = async () => {
|
||||
const scan = await scanBookings();
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const counts = new Map();
|
||||
|
||||
(scan.rows || []).forEach((b) => {
|
||||
if (!isRepeatable(b)) return;
|
||||
const day = dayOf(b.createdat);
|
||||
if (!day || day === today) return;
|
||||
if (dayjs(today).diff(dayjs(day), 'day') > LOOKBACK_DAYS) return;
|
||||
counts.set(day, (counts.get(day) || 0) + 1);
|
||||
});
|
||||
|
||||
return {
|
||||
scan,
|
||||
runs: [...counts.entries()].map(([day, count]) => ({ day, count })).sort((a, b) => (a.day < b.day ? 1 : -1))
|
||||
};
|
||||
};
|
||||
|
||||
export const describeDay = (day) => {
|
||||
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||
if (day === yesterday) return 'Yesterday';
|
||||
return dayjs(day).format('ddd D MMM');
|
||||
};
|
||||
|
||||
// ---- one booking → one repeatable row ---------------------------------------
|
||||
//
|
||||
// `__pickup` travels on the ROW, not on the shared draft: a day's run can span
|
||||
// several kitchens and tenants, and collapsing them onto one shared pickup
|
||||
// would silently re-address half the orders.
|
||||
const toRow = (booking, customer, index) => ({
|
||||
line: index + 1,
|
||||
source: booking.bookingno || `#${booking.bookingid}`,
|
||||
tenantid: booking.tenantid,
|
||||
customer_name: customer?.name || [customer?.firstname, customer?.lastname].filter(Boolean).join(' ') || '',
|
||||
customer_phone: String(customer?.phone || customer?.contactno || '').replace(/\D/g, ''),
|
||||
deliveryaddress: booking.deliveryaddress || '',
|
||||
deliverypincode: String(booking.deliverypincode || ''),
|
||||
deliverycity: booking.deliverycity || '',
|
||||
deliverylatitude: booking.deliverylatitude,
|
||||
deliverylongitude: booking.deliverylongitude,
|
||||
service_option: booking.serviceoptions?.[0]?.servicetype || 'Normal',
|
||||
// Deliberately blank: the chosen behaviour is to re-quote at today's tariff,
|
||||
// so this is left for priceBulkRows to fill exactly as an unpriced bulk row
|
||||
// would be. Copying yesterday's number would silently bill an old tariff.
|
||||
finalprice: '',
|
||||
previousPrice: booking.serviceoptions?.[0]?.estimatedprice,
|
||||
itemcategory: booking.parcels?.[0]?.itemcategory || 'General',
|
||||
itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
|
||||
quantity: Math.max(1, booking.parcels?.length || 1),
|
||||
notes: booking.notes || '',
|
||||
__pickup: {
|
||||
address: booking.pickupaddress,
|
||||
pincode: booking.pickuppincode,
|
||||
city: booking.pickupcity,
|
||||
latitude: booking.pickuplatitude,
|
||||
longitude: booking.pickuplongitude
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the drift check --------------------------------------------------------
|
||||
//
|
||||
// Returns a REASON, never a boolean — an operator dropping a row deserves to
|
||||
// know which field went stale.
|
||||
const driftReason = (row) => {
|
||||
if (!row.tenantid) return 'the original had no tenant, so this would be booked against tenant 0';
|
||||
if (!row.__pickup?.address) return 'the original booking carries no pickup address';
|
||||
if (!row.customer_phone) return 'the customer record is gone, so there is no phone number';
|
||||
if (!row.customer_name) return 'the customer record is gone, so there is no name';
|
||||
if (!row.deliveryaddress) return 'no delivery address on the original';
|
||||
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
|
||||
return 'the original has no delivery coordinates, so it could never be routed';
|
||||
}
|
||||
if (!cityGateFor(row.__pickup.pincode)) {
|
||||
return `its pickup pincode (${row.__pickup.pincode || 'unknown'}) is no longer a city Doormile serves`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// What makes two orders "the same order". It has to include the PARCEL, not
|
||||
// just the destination: a run to one address for one customer is a completely
|
||||
// normal thing to do twice in a day with different contents, and judging on
|
||||
// phone + address + pickup alone made three unrelated bookings to the same bus
|
||||
// stand look identical — the repeat then reported all of yesterday as already
|
||||
// created when only one unrelated order existed today.
|
||||
//
|
||||
// Every field here is one a repeat reproduces EXACTLY, which is what makes it a
|
||||
// usable identity. Price is deliberately excluded: it is re-quoted at today's
|
||||
// tariff, so it differs by design on every legitimate repeat.
|
||||
const fingerprint = (row) =>
|
||||
[
|
||||
row.customer_phone,
|
||||
String(row.deliveryaddress || '').toLowerCase(),
|
||||
row.__pickup?.pincode || '',
|
||||
row.service_option,
|
||||
row.itemcategory,
|
||||
row.itemdescription,
|
||||
row.quantity
|
||||
].join('|');
|
||||
|
||||
// ---- assemble the run -------------------------------------------------------
|
||||
export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
|
||||
const [{ scan }, customers] = await Promise.all([findRecentRuns(), getAdminCustomers().catch(() => [])]);
|
||||
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.id, c]));
|
||||
|
||||
const source = (scan.rows || []).filter((b) => isRepeatable(b) && dayOf(b.createdat) === day);
|
||||
const rows = source.map((b, i) => toRow(b, customerMap.get(b.appcustomerid), i));
|
||||
|
||||
// Already repeated today? Today's own bookings are put through the SAME
|
||||
// `toRow` shaping, so both sides of the comparison get identical defaults —
|
||||
// hand-rolling the today side is how the two drifted apart in the first place.
|
||||
//
|
||||
// Counts, not a Set. A Set answers "does anything today look like this", so a
|
||||
// single matching order suppressed EVERY row that shared its fingerprint —
|
||||
// one order created today wiped out all of yesterday's. A multiset answers
|
||||
// the question that was actually meant: how many of these already exist. Two
|
||||
// identical orders yesterday and one today means one still needs creating.
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const todayCounts = new Map();
|
||||
(scan.rows || [])
|
||||
.filter((b) => isRepeatable(b) && dayOf(b.createdat) === today)
|
||||
.forEach((b) => {
|
||||
const key = fingerprint(toRow(b, customerMap.get(b.appcustomerid), 0));
|
||||
todayCounts.set(key, (todayCounts.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const drifted = [];
|
||||
const already = [];
|
||||
const candidates = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const reason = driftReason(row);
|
||||
if (reason) {
|
||||
drifted.push({ ...row, error: reason });
|
||||
return;
|
||||
}
|
||||
// Consume one match per already-existing order, so a second identical row
|
||||
// is still offered once the first has been accounted for.
|
||||
const key = fingerprint(row);
|
||||
const remaining = todayCounts.get(key) || 0;
|
||||
if (remaining > 0) {
|
||||
todayCounts.set(key, remaining - 1);
|
||||
already.push(row);
|
||||
return;
|
||||
}
|
||||
candidates.push(row);
|
||||
});
|
||||
|
||||
// Re-quote at today's tariff. Priced per row because a run can span tenants,
|
||||
// and a tenant's own pricing row is what decides the number.
|
||||
const priced = [];
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = candidates[i];
|
||||
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
|
||||
priced.push(out);
|
||||
}
|
||||
if (priced.length < candidates.length) priced.push(...candidates.slice(priced.length));
|
||||
|
||||
const valid = priced.filter((r) => !r.priceError && Number(r.finalprice) > 0);
|
||||
const unpriced = priced.filter((r) => r.priceError || !(Number(r.finalprice) > 0));
|
||||
|
||||
return {
|
||||
day,
|
||||
scan,
|
||||
total: rows.length,
|
||||
valid,
|
||||
drifted,
|
||||
already,
|
||||
unpriced,
|
||||
// Every price that moved since the original, so a tariff change is visible
|
||||
// rather than discovered on an invoice.
|
||||
changed: valid.filter((r) => r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice))
|
||||
};
|
||||
};
|
||||
@@ -799,7 +799,23 @@ const Deliveries = () => {
|
||||
Status
|
||||
</Text>
|
||||
),
|
||||
cell: ({ getValue }) => <StatusBadge status={getValue()} />
|
||||
// The badge explains itself when the value did not come from the
|
||||
// booking. Without this an operator comparing the table against
|
||||
// GET /admin/bookings sees "Active" next to
|
||||
// `"status": "Converted_To_Consignment"` and reasonably assumes the
|
||||
// page is wrong — it isn't; the booking simply stops moving at pickup.
|
||||
cell: ({ getValue, row }) =>
|
||||
row.original.statusfromconsignment ? (
|
||||
<Tooltip
|
||||
content={`From consignment #${row.original.consignmentid} (${row.original.consignmentstatus}). The booking itself stays at Converted_To_Consignment once it has been picked up.`}
|
||||
>
|
||||
<span>
|
||||
<StatusBadge status={getValue()} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<StatusBadge status={getValue()} />
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'tenant',
|
||||
|
||||
@@ -54,6 +54,14 @@ An opt-in "activity" date basis was added so an order booked yesterday and still
|
||||
|
||||
The filter field and the bucket field have to be the same field. If carried-over work needs to be visible on the board, it needs its own bucket ("Carried over") — not a time-of-day wave it does not belong to. Decide that before reaching for the date filter again.
|
||||
|
||||
### Picked is empty for hyperlocal traffic, and that is correct
|
||||
|
||||
`doormile-flow.md` §5: at `pickup-complete` the booking becomes a consignment, and **matching 3-digit pickup/delivery pincode prefixes go straight to `Out_for_Delivery`** (hyperlocal); everything else routes via a hub.
|
||||
|
||||
So a Coimbatore-to-Coimbatore order (641xxx → 641xxx) is picked up and is *immediately* out for delivery. It never rests in a "picked" state. The Picked tab reading 0 while orders are plainly moving is expected behaviour on hyperlocal traffic, not a missing mapping.
|
||||
|
||||
The confusing part is downstream of that: the booking's own `status` **freezes at `Converted_To_Consignment`** the moment it is picked up. The Deliveries page joins `GET /admin/consignments` and lets the consignment's status win, so a row correctly reads **Active** while `GET /admin/bookings` still says `Converted_To_Consignment`. That looks like a bug on inspection, which is why the status badge carries a tooltip naming the consignment and its status whenever the value came from there (`statusfromconsignment` on the row).
|
||||
|
||||
### ⛔ Never bucket on `assigntime`
|
||||
It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it.
|
||||
|
||||
|
||||
@@ -62,7 +62,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
/* Tighter on the LEFT than the right. The logo is the page's anchor and was
|
||||
sitting 24px in; the right side keeps the wider gutter because the controls
|
||||
there need clearance from the window edge. #strat-row below carries the
|
||||
same asymmetry — the two are stacked, so their left edges have to agree or
|
||||
the logo no longer lines up with the batch row under it. */
|
||||
padding: 0 24px 0 12px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1010;
|
||||
@@ -74,17 +79,29 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* The badge is sized to the D ITSELF, not to the image file.
|
||||
doormile-mark.png fills only 66.8% of its own canvas (alpha bounding box
|
||||
170..853 of 1024) — a third of every edge is transparent padding. In the old
|
||||
44px box that drew a 29px D with 7.3px of empty space on each side, so the
|
||||
12px gap to "Dispatch" read as ~19px. That whitespace was the "large gap",
|
||||
not the gap property.
|
||||
The image is scaled 150% (1 / 0.668) to cancel the padding exactly, and
|
||||
overflow clips what spills — which is only transparent pixels. Net effect:
|
||||
the D stays the size it already looked, and the gap becomes a true 12px. */
|
||||
.dispatch-container .logo-badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dispatch-container .logo-badge-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -287,7 +304,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 24px;
|
||||
/* Same left gutter as #hdr — see the note there. */
|
||||
padding: 0 24px 0 12px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user